use std::io::Write;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::args::{ManifestArgs, ManifestFormat, OutputMode};
use code_moniker_core::core::uri::UriConfig;
use code_moniker_core::lang::build_manifest::{Dep, Manifest, parse};
const DEFAULT_PROJECT: &[u8] = b".";
pub fn run<W1: Write, W2: Write>(args: &ManifestArgs, stdout: &mut W1, stderr: &mut W2) -> i32 {
match run_inner(args, stdout) {
Ok(any) => {
if any {
0
} else {
1
}
}
Err(e) => {
let _ = writeln!(stderr, "code-moniker: {e:#}");
2
}
}
}
fn run_inner<W: Write>(args: &ManifestArgs, stdout: &mut W) -> anyhow::Result<bool> {
let path: &Path = &args.path;
let scheme = args
.scheme
.as_deref()
.unwrap_or(crate::DEFAULT_SCHEME)
.to_string();
let meta = std::fs::metadata(path)
.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", path.display()))?;
let entries = if meta.is_dir() {
scan_dir(path)
} else {
scan_single(path)?
};
let any = !entries.is_empty();
match args.mode() {
OutputMode::Default => match args.format {
ManifestFormat::Tsv => write_tsv(stdout, &entries, &scheme)?,
ManifestFormat::Json => write_json(stdout, &entries, &scheme)?,
#[cfg(feature = "pretty")]
ManifestFormat::Tree => write_tree(stdout, &entries, &scheme)?,
},
OutputMode::Count => writeln!(stdout, "{}", entries.len())?,
OutputMode::Quiet => {}
}
Ok(any)
}
struct Entry {
manifest_uri: String,
manifest_kind: Manifest,
dep: Dep,
}
fn scan_single(path: &Path) -> anyhow::Result<Vec<Entry>> {
let manifest = Manifest::for_filename(path).ok_or_else(|| {
anyhow::anyhow!(
"{}: filename not recognised as a build manifest (expected Cargo.toml / package.json / pom.xml / pyproject.toml / go.mod / *.csproj)",
path.display()
)
})?;
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
let deps = parse(manifest, DEFAULT_PROJECT, &content)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
let manifest_uri = path.display().to_string();
Ok(deps
.into_iter()
.map(|dep| Entry {
manifest_uri: manifest_uri.clone(),
manifest_kind: manifest,
dep,
})
.collect())
}
fn scan_dir(root: &Path) -> Vec<Entry> {
use rayon::prelude::*;
let manifests: Vec<(PathBuf, Manifest)> = ignore::WalkBuilder::new(root)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.filter_map(|e| {
let p = e.into_path();
let m = Manifest::for_filename(&p)?;
Some((p, m))
})
.collect();
let mut entries: Vec<Entry> = manifests
.par_iter()
.flat_map(|(path, manifest)| {
let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("code-moniker: cannot read {}: {e}", path.display());
return Vec::new();
}
};
let deps = match parse(*manifest, DEFAULT_PROJECT, &content) {
Ok(d) => d,
Err(e) => {
eprintln!("code-moniker: {}: {e}", path.display());
return Vec::new();
}
};
let manifest_uri = rel.display().to_string();
deps.into_iter()
.map(|dep| Entry {
manifest_uri: manifest_uri.clone(),
manifest_kind: *manifest,
dep,
})
.collect()
})
.collect();
entries.sort_by(|a, b| {
a.manifest_uri
.cmp(&b.manifest_uri)
.then_with(|| a.dep.import_root.cmp(&b.dep.import_root))
.then_with(|| a.dep.dep_kind.cmp(&b.dep.dep_kind))
});
entries
}
fn render(m: &code_moniker_core::core::moniker::Moniker, scheme: &str) -> String {
crate::render_uri(m, &UriConfig { scheme })
}
fn write_tsv<W: Write>(w: &mut W, entries: &[Entry], scheme: &str) -> std::io::Result<()> {
for e in entries {
writeln!(
w,
"{moniker}\t{manifest}\t{name}\t{import_root}\t{version}\t{dep_kind}",
moniker = render(&e.dep.package_moniker, scheme),
manifest = e.manifest_uri,
name = e.dep.name,
import_root = e.dep.import_root,
version = e.dep.version.as_deref().unwrap_or(""),
dep_kind = e.dep.dep_kind,
)?;
}
Ok(())
}
#[derive(Serialize)]
struct JsonRow<'a> {
package_moniker: String,
manifest_uri: &'a str,
manifest_kind: &'static str,
name: &'a str,
import_root: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
dep_kind: &'a str,
}
#[cfg(feature = "pretty")]
fn write_tree<W: Write>(w: &mut W, entries: &[Entry], scheme: &str) -> std::io::Result<()> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<&str, Vec<&Entry>> = BTreeMap::new();
for e in entries {
groups.entry(e.manifest_uri.as_str()).or_default().push(e);
}
for (uri, rows) in groups {
writeln!(w, "{uri}")?;
let last = rows.len();
for (i, e) in rows.iter().enumerate() {
let glyph = if i + 1 == last { "└─" } else { "├─" };
let version = e.dep.version.as_deref().unwrap_or("-");
writeln!(
w,
" {glyph} {name} {version} ({kind}) {moniker}",
name = e.dep.import_root,
kind = e.dep.dep_kind,
moniker = render(&e.dep.package_moniker, scheme),
)?;
}
}
Ok(())
}
fn write_json<W: Write>(w: &mut W, entries: &[Entry], scheme: &str) -> anyhow::Result<()> {
let rows: Vec<JsonRow<'_>> = entries
.iter()
.map(|e| JsonRow {
package_moniker: render(&e.dep.package_moniker, scheme),
manifest_uri: &e.manifest_uri,
manifest_kind: e.manifest_kind.tag(),
name: &e.dep.name,
import_root: &e.dep.import_root,
version: e.dep.version.as_deref(),
dep_kind: &e.dep.dep_kind,
})
.collect();
serde_json::to_writer_pretty(&mut *w, &rows)?;
w.write_all(b"\n")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn args_for(path: PathBuf, format: ManifestFormat) -> ManifestArgs {
ManifestArgs {
path,
format,
count: false,
quiet: false,
scheme: None,
}
}
#[test]
fn single_file_emits_tsv_row_per_dep() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("package.json");
fs::write(
&p,
r#"{"name":"demo","version":"0.1.0","dependencies":{"react":"^18"}}"#,
)
.unwrap();
let args = args_for(p, ManifestFormat::Tsv);
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 0);
let text = String::from_utf8(out).unwrap();
assert!(text.contains("code+moniker://./external_pkg:demo"));
assert!(text.contains("code+moniker://./external_pkg:react"));
assert!(text.contains("\treact\t"));
assert!(text.contains("\tnormal"));
}
#[test]
fn single_file_emits_json_array() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("Cargo.toml");
fs::write(
&p,
"[package]\nname=\"demo\"\nversion=\"0.1.0\"\n\n[dependencies]\nserde-json = \"1\"\n",
)
.unwrap();
let args = args_for(p, ManifestFormat::Json);
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 0);
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
let rows = v.as_array().expect("array");
assert!(rows.iter().any(|r| r["import_root"] == "serde_json"
&& r["package_moniker"] == "code+moniker://./external_pkg:serde_json"));
assert!(rows.iter().any(|r| r["manifest_kind"] == "cargo"));
}
#[test]
fn dir_mode_walks_every_manifest_kind() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("package.json"),
r#"{"name":"a","dependencies":{"react":"^18"}}"#,
)
.unwrap();
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(
root.join("sub/Cargo.toml"),
"[package]\nname=\"b\"\nversion=\"0\"\n\n[dependencies]\nserde = \"1\"\n",
)
.unwrap();
let args = args_for(root.to_path_buf(), ManifestFormat::Tsv);
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 0);
let text = String::from_utf8(out).unwrap();
assert!(text.contains("\tpackage.json\t"));
assert!(text.contains("\tsub/Cargo.toml\t"));
assert!(text.contains("external_pkg:react"));
assert!(text.contains("external_pkg:serde"));
}
#[test]
fn unknown_filename_reports_usage_error() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("README.md");
fs::write(&p, "").unwrap();
let args = args_for(p, ManifestFormat::Tsv);
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 2);
let err_text = String::from_utf8(err).unwrap();
assert!(err_text.contains("filename not recognised"));
}
#[test]
fn empty_dir_exits_with_no_match() {
let tmp = tempfile::tempdir().unwrap();
let args = args_for(tmp.path().to_path_buf(), ManifestFormat::Tsv);
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 1);
}
#[test]
fn count_mode_prints_total_rows() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("package.json");
fs::write(&p, r#"{"name":"demo","dependencies":{"a":"1","b":"2"}}"#).unwrap();
let mut args = args_for(p, ManifestFormat::Tsv);
args.count = true;
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 0);
assert_eq!(String::from_utf8(out).unwrap(), "3\n");
}
#[test]
fn quiet_mode_emits_nothing() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("package.json");
fs::write(&p, r#"{"name":"demo"}"#).unwrap();
let mut args = args_for(p, ManifestFormat::Tsv);
args.quiet = true;
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 0);
assert!(out.is_empty());
}
#[cfg(feature = "pretty")]
#[test]
fn tree_format_groups_rows_by_manifest() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("package.json");
fs::write(
&p,
r#"{"name":"demo","dependencies":{"react":"^18"},"devDependencies":{"vitest":"1"}}"#,
)
.unwrap();
let args = args_for(p, ManifestFormat::Tree);
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 0);
let text = String::from_utf8(out).unwrap();
assert!(text.contains("package.json\n"), "{text}");
assert!(text.contains("react"), "{text}");
assert!(text.contains("└─"), "{text}");
assert!(text.contains("(dev)"), "{text}");
assert!(
text.contains("code+moniker://./external_pkg:react"),
"{text}"
);
}
#[test]
fn custom_scheme_round_trips_in_tsv() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("package.json");
fs::write(&p, r#"{"name":"demo","dependencies":{"react":"^18"}}"#).unwrap();
let mut args = args_for(p, ManifestFormat::Tsv);
args.scheme = Some("acme://".into());
let mut out = Vec::new();
let mut err = Vec::new();
assert_eq!(run(&args, &mut out, &mut err), 0);
let text = String::from_utf8(out).unwrap();
assert!(text.contains("acme://./external_pkg:react"));
}
}