Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
345 changes: 161 additions & 184 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lisensor"
version = "0.2.2"
version = "0.3.0"
edition = "2024"
description = "Tool to automatically add, check, and fix license notices in the source files"
license = "MIT"
Expand All @@ -22,7 +22,7 @@ serde = "1"

[dependencies.cu]
package = "pistonite-cu"
version = "0.7.4"
version = "0.9.0"
features = ["print", "fs", "coroutine-heavy", "toml"]
# path = "../cu/packages/copper"

Expand Down
3 changes: 1 addition & 2 deletions Lisensor.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
[Pistonite]
"src/**/*" = "MIT"
"tests/**/*.rs" = "MIT"
"{src,tests}/**/*.rs" = "MIT"
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ used by tests in the project, but might be helpful for integrating `lisensor` in

## Config
By default, `lisensor` looks for `Lisensor.toml` then `lisensor.toml`
in the current directory if no config files are specified.
in the current directory if no config files are specified. If not found
then it tries to search the parent directories until one is found.

Globs in the config file are relative to the directory containing
the config file, meaning running `lisensor` from anywhere will result
Expand Down
3 changes: 1 addition & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ tasks:
- cargo test --test fixture_test

test-inline-cmd:
dir: src
cmds:
- cargo run -- -H Pistonite -L MIT **/*.rs
- cargo run -- -H Pistonite -L MIT './**/*.rs' --no-config

fix:
- cargo run -- -f
Expand Down
32 changes: 24 additions & 8 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025-2026 Pistonite

use std::path::Path;

use cu::pre::*;

use crate::Config;
Expand All @@ -18,24 +20,38 @@ pub struct Cli {
#[clap(short = 'L', long, requires("holder"))]
pub license: Option<String>,

/// Do not attempt discovering config file
#[clap(long, requires("holder"))]
pub no_config: bool,

#[clap(flatten)]
pub common: cu::cli::Flags,

/// Paths to config files, or in inline config mode, glob patterns for source files
/// to apply the license notice.
///
/// In inline config mode, the glob patterns are relative to the current working directory
pub paths: Vec<String>,
}

/// Convert the CLI args into configuration object
pub fn config_from_cli(args: &mut crate::Cli) -> cu::Result<Config> {
match (args.holder.take(), args.license.take()) {
(Some(holder), Some(license)) => {
if let Some(config_path) = crate::try_find_default_config_file() {
cu::bail!(
"--holder or --license cannot be specified when {config_path} is present in the current directory"
);
if !args.no_config {
if let Some(config_path) = crate::try_find_default_config_file() {
cu::error!(
"--holder or --license cannot be specified when config file is present: '{}'",
config_path.display()
);
cu::hint!(
"this error exists to prevent accidental misuse; use --no-config to skip discovering config file if intended"
);
cu::bail!("invalid command line usage");
}
}
Ok(Config::new(
std::env::current_dir()?,
holder,
license,
std::mem::take(&mut args.paths),
Expand All @@ -48,16 +64,16 @@ pub fn config_from_cli(args: &mut crate::Cli) -> cu::Result<Config> {
None => {
let Some(config_path) = crate::try_find_default_config_file() else {
cu::bail!(
"cannot find Lisensor.toml, and no config files are specified on the command line."
"cannot find config file, and no config files are specified on the command line."
);
};
Config::build(config_path)?
Config::build(&config_path)?
}
Some(first) => Config::build(&first)?,
Some(first) => Config::build(Path::new(&first))?,
};

for path in iter {
config.absorb(Config::build(&path)?)?;
config.absorb(Config::build(Path::new(&path))?)?;
}

Ok(config)
Expand Down
104 changes: 77 additions & 27 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,45 @@

use std::collections::BTreeMap;
use std::ops::Deref;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use cu::pre::*;

static CONFIG_NAMES: &[&str] = &["Lisensor.toml", "lisensor.toml"];

/// Try finding the default config files according to the order
/// specified in the documentation (see repo README)
pub fn try_find_default_config_file() -> Option<&'static str> {
["Lisensor.toml", "lisensor.toml"]
.into_iter()
.find(|x| Path::new(x).exists())
pub fn try_find_default_config_file() -> Option<PathBuf> {
for x in CONFIG_NAMES {
if Path::new(x).exists() {
cu::debug!("found config {x} in current directory");
return Some(PathBuf::from(x));
}
}
cu::debug!("discovering config in parent directories");
let mut curr = Path::new(".").normalize().ok()?;
loop {
curr = curr.parent_abs().ok()?;
cu::debug!("looking for config in '{}'", curr.display());
let mut p = curr.clone();
for x in CONFIG_NAMES {
p.push(x);
if p.exists() {
cu::debug!("found config '{}'", p.display());
return Some(p);
}
p.pop();
}
}
}

/// Config object
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Config {
// glob -> (holder, license)
/// the root directory to run the program
root: PathBuf,
/// glob -> (holder, license)
globs: BTreeMap<String, (Arc<String>, Arc<String>)>,
}

Expand All @@ -32,7 +54,7 @@ struct TomlConfig(BTreeMap<String, BTreeMap<String, String>>);
impl Config {
/// Create a config object from a single holder and license,
/// with multiple glob patterns.
pub fn new(holder: String, license: String, glob_list: Vec<String>) -> Self {
pub fn new(root: PathBuf, holder: String, license: String, glob_list: Vec<String>) -> Self {
let holder = Arc::new(holder);
let license = Arc::new(license);
let mut globs = BTreeMap::new();
Expand All @@ -48,25 +70,22 @@ impl Config {
}
}
}
Self { globs }
Self { root, globs }
}

/// Build the config by reading the file specified, error if conflicts are detected
///
/// The globs specified in the config file are relative to the parent directory
/// of `path`.
pub fn build(path: &str) -> cu::Result<Self> {
let raw = toml::parse::<TomlConfig>(&cu::fs::read_string(path)?)?;
let parent = Path::new(path)
.parent()
/// of `config_path`.
pub fn build(config_path: &Path) -> cu::Result<Self> {
let raw = toml::parse::<TomlConfig>(&cu::fs::read_string(config_path)?)?;
let root = config_path
.parent_abs()
.context("failed to get parent path for config")?;
let mut globs = BTreeMap::new();
for (holder, table) in raw.0 {
let holder = Arc::new(holder);
for (glob, license) in table {
// globs in config files are resolved relative
// to the directory where the config file is in
let glob = parent.join(glob).into_utf8()?;
use std::collections::btree_map::Entry;
match globs.entry(glob) {
Entry::Vacant(entry) => {
Expand All @@ -76,7 +95,10 @@ impl Config {
let glob = entry.key();
let (curr_holder, curr_license) = entry.get();
if *curr_holder == holder && curr_license.deref() == license.as_str() {
cu::warn!("glob '{glob}' specified multiple times in '{path}'!");
cu::warn!(
"glob '{glob}' specified multiple times in '{}'!",
config_path.display()
);
continue;
}
cu::error!("conflicting config specified for glob '{glob}':");
Expand All @@ -91,7 +113,10 @@ impl Config {
}
}
}
Ok(Self { globs })
Ok(Self {
root: root.to_path_buf(),
globs,
})
}

/// Merge another config into self, error if conflicts are detected
Expand Down Expand Up @@ -124,14 +149,39 @@ impl Config {
}
}

impl Config {
/// Iterate the resolve paths as (path, holder, license)
#[allow(clippy::should_implement_trait)]
pub fn into_iter(self) -> impl Iterator<Item = (String, Arc<String>, Arc<String>)> {
// we can't implement the IntoIterator trait because
// the map object has an unnamed function type
self.globs
.into_iter()
.map(|(path, (holder, license))| (path, holder, license))
impl IntoIterator for Config {
type Item = ConfigEntry;
type IntoIter = ConfigIntoIter;
fn into_iter(self) -> Self::IntoIter {
ConfigIntoIter {
root: Arc::new(self.root),
globs_iter: self.globs.into_iter(),
}
}
}

pub struct ConfigIntoIter {
root: Arc<PathBuf>,
globs_iter: std::collections::btree_map::IntoIter<String, (Arc<String>, Arc<String>)>,
}

impl Iterator for ConfigIntoIter {
type Item = ConfigEntry;
fn next(&mut self) -> Option<Self::Item> {
let (glob, (holder, license)) = self.globs_iter.next()?;
Some(ConfigEntry {
root: Arc::clone(&self.root),
glob,
holder,
license,
})
}
}

/// Config for one file
pub struct ConfigEntry {
pub root: Arc<PathBuf>,
pub glob: String,
pub holder: Arc<String>,
pub license: Arc<String>,
}
47 changes: 27 additions & 20 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;

use crate::{Config, format};
use crate::{Config, ConfigEntry, format};

/// Issues found
#[derive(Debug, Default, Clone, PartialEq)]
Expand Down Expand Up @@ -45,16 +45,14 @@ pub async fn run(config: Config, fix: bool) -> cu::Result<Result<(), Failure>> {

// avoid opening too many files. max open 1024 files
let pool = cu::co::pool(1024);
for (glob, holder, license) in config.into_iter() {
let result = run_glob(
&glob,
holder,
license,
fix,
&pool,
&mut handles,
&mut path_map,
for entry in config {
let glob = entry.glob.clone();
cu::debug!(
"running glob '{glob}', holder={:?}, license={:?}",
entry.holder,
entry.license
);
let result = run_glob_config(entry, fix, &pool, &mut handles, &mut path_map);
match result {
Ok(matched) => {
if !matched {
Expand All @@ -75,7 +73,7 @@ pub async fn run(config: Config, fix: bool) -> cu::Result<Result<(), Failure>> {
// handle glob errors first
if !glob_errors.is_empty() {
for (glob, error) in &glob_errors {
cu::error!("while globbing '{glob}': {error}");
cu::error!("while globbing '{glob}': {error:?}");
}
cu::error!(
"got {} errors while searching for files, see above",
Expand All @@ -95,6 +93,13 @@ pub async fn run(config: Config, fix: bool) -> cu::Result<Result<(), Failure>> {
cu::progress!(bar += 1, "{}", path.display());
}

if total == 0 {
cu::bail!("input did not match any files");
}
for x in no_match_glob {
cu::warn!("glob '{x}' did not match any files!");
}

if !errors.is_empty() {
let failed = errors.len();
cu::error!("checked {total} files, found {failed} issue(s).");
Expand All @@ -109,24 +114,26 @@ pub async fn run(config: Config, fix: bool) -> cu::Result<Result<(), Failure>> {
Ok(Ok(()))
}

fn run_glob(
glob: &str,
holder: Arc<String>,
license: Arc<String>,
fn run_glob_config(
config: ConfigEntry,
fix: bool,
pool: &cu::co::Pool,
handles: &mut Vec<cu::co::Handle<(PathBuf, cu::Result<()>)>>,
path_map: &mut BTreeMap<PathBuf, (Arc<String>, Arc<String>)>,
) -> cu::Result<bool> {
let mut matched = false;
for path in cu::fs::glob(glob)? {
let path = path?;
if !path.is_file() {
let mut walker = cu::fs::walker(&*config.root);
let glob = &config.glob;
walker.git(true).glob_includes([glob])?;
for entry in walker.walk()? {
let entry = entry?;
if !entry.is_file() {
continue;
}
matched = true;
let holder = Arc::clone(&holder);
let license = Arc::clone(&license);
let holder = Arc::clone(&config.holder);
let license = Arc::clone(&config.license);
let path = entry.path().to_path_buf();

// in fix mode, run additional check for if there are conflicts
// in the config. Otherwise, the fix result is arbitrary
Expand Down
Loading
Loading