Skip to main content

code_moniker_cli/
manifest.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use crate::args::{ManifestArgs, ManifestFormat, OutputMode};
7use code_moniker_core::core::uri::UriConfig;
8use code_moniker_core::lang::build_manifest::{Dep, Manifest, parse};
9
10const DEFAULT_PROJECT: &[u8] = b".";
11
12pub fn run<W1: Write, W2: Write>(args: &ManifestArgs, stdout: &mut W1, stderr: &mut W2) -> i32 {
13	match run_inner(args, stdout) {
14		Ok(any) => {
15			if any {
16				0
17			} else {
18				1
19			}
20		}
21		Err(e) => {
22			let _ = writeln!(stderr, "code-moniker: {e:#}");
23			2
24		}
25	}
26}
27
28fn run_inner<W: Write>(args: &ManifestArgs, stdout: &mut W) -> anyhow::Result<bool> {
29	let path: &Path = &args.path;
30	let scheme = args
31		.scheme
32		.as_deref()
33		.unwrap_or(crate::DEFAULT_SCHEME)
34		.to_string();
35	let meta = std::fs::metadata(path)
36		.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", path.display()))?;
37	let entries = if meta.is_dir() {
38		scan_dir(path)
39	} else {
40		scan_single(path)?
41	};
42	let any = !entries.is_empty();
43	match args.mode() {
44		OutputMode::Default => match args.format {
45			ManifestFormat::Tsv => write_tsv(stdout, &entries, &scheme)?,
46			ManifestFormat::Json => write_json(stdout, &entries, &scheme)?,
47			#[cfg(feature = "pretty")]
48			ManifestFormat::Tree => write_tree(stdout, &entries, &scheme)?,
49		},
50		OutputMode::Count => writeln!(stdout, "{}", entries.len())?,
51		OutputMode::Quiet => {}
52	}
53	Ok(any)
54}
55
56struct Entry {
57	manifest_uri: String,
58	manifest_kind: Manifest,
59	dep: Dep,
60}
61
62fn scan_single(path: &Path) -> anyhow::Result<Vec<Entry>> {
63	let manifest = Manifest::for_filename(path).ok_or_else(|| {
64		anyhow::anyhow!(
65			"{}: filename not recognised as a build manifest (expected Cargo.toml / package.json / pom.xml / pyproject.toml / go.mod / *.csproj)",
66			path.display()
67		)
68	})?;
69	let content = std::fs::read_to_string(path)
70		.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
71	let deps = parse(manifest, DEFAULT_PROJECT, &content)
72		.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
73	let manifest_uri = path.display().to_string();
74	Ok(deps
75		.into_iter()
76		.map(|dep| Entry {
77			manifest_uri: manifest_uri.clone(),
78			manifest_kind: manifest,
79			dep,
80		})
81		.collect())
82}
83
84fn scan_dir(root: &Path) -> Vec<Entry> {
85	use rayon::prelude::*;
86	let manifests: Vec<(PathBuf, Manifest)> = ignore::WalkBuilder::new(root)
87		.build()
88		.filter_map(|e| e.ok())
89		.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
90		.filter_map(|e| {
91			let p = e.into_path();
92			let m = Manifest::for_filename(&p)?;
93			Some((p, m))
94		})
95		.collect();
96	let mut entries: Vec<Entry> = manifests
97		.par_iter()
98		.flat_map(|(path, manifest)| {
99			let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
100			let content = match std::fs::read_to_string(path) {
101				Ok(s) => s,
102				Err(e) => {
103					eprintln!("code-moniker: cannot read {}: {e}", path.display());
104					return Vec::new();
105				}
106			};
107			let deps = match parse(*manifest, DEFAULT_PROJECT, &content) {
108				Ok(d) => d,
109				Err(e) => {
110					eprintln!("code-moniker: {}: {e}", path.display());
111					return Vec::new();
112				}
113			};
114			let manifest_uri = rel.display().to_string();
115			deps.into_iter()
116				.map(|dep| Entry {
117					manifest_uri: manifest_uri.clone(),
118					manifest_kind: *manifest,
119					dep,
120				})
121				.collect()
122		})
123		.collect();
124	entries.sort_by(|a, b| {
125		a.manifest_uri
126			.cmp(&b.manifest_uri)
127			.then_with(|| a.dep.import_root.cmp(&b.dep.import_root))
128			.then_with(|| a.dep.dep_kind.cmp(&b.dep.dep_kind))
129	});
130	entries
131}
132
133fn render(m: &code_moniker_core::core::moniker::Moniker, scheme: &str) -> String {
134	crate::render_uri(m, &UriConfig { scheme })
135}
136
137fn write_tsv<W: Write>(w: &mut W, entries: &[Entry], scheme: &str) -> std::io::Result<()> {
138	for e in entries {
139		writeln!(
140			w,
141			"{moniker}\t{manifest}\t{name}\t{import_root}\t{version}\t{dep_kind}",
142			moniker = render(&e.dep.package_moniker, scheme),
143			manifest = e.manifest_uri,
144			name = e.dep.name,
145			import_root = e.dep.import_root,
146			version = e.dep.version.as_deref().unwrap_or(""),
147			dep_kind = e.dep.dep_kind,
148		)?;
149	}
150	Ok(())
151}
152
153#[derive(Serialize)]
154struct JsonRow<'a> {
155	package_moniker: String,
156	manifest_uri: &'a str,
157	manifest_kind: &'static str,
158	name: &'a str,
159	import_root: &'a str,
160	#[serde(skip_serializing_if = "Option::is_none")]
161	version: Option<&'a str>,
162	dep_kind: &'a str,
163}
164
165#[cfg(feature = "pretty")]
166fn write_tree<W: Write>(w: &mut W, entries: &[Entry], scheme: &str) -> std::io::Result<()> {
167	use std::collections::BTreeMap;
168	let mut groups: BTreeMap<&str, Vec<&Entry>> = BTreeMap::new();
169	for e in entries {
170		groups.entry(e.manifest_uri.as_str()).or_default().push(e);
171	}
172	for (uri, rows) in groups {
173		writeln!(w, "{uri}")?;
174		let last = rows.len();
175		for (i, e) in rows.iter().enumerate() {
176			let glyph = if i + 1 == last { "└─" } else { "├─" };
177			let version = e.dep.version.as_deref().unwrap_or("-");
178			writeln!(
179				w,
180				"  {glyph} {name}  {version}  ({kind})  {moniker}",
181				name = e.dep.import_root,
182				kind = e.dep.dep_kind,
183				moniker = render(&e.dep.package_moniker, scheme),
184			)?;
185		}
186	}
187	Ok(())
188}
189
190fn write_json<W: Write>(w: &mut W, entries: &[Entry], scheme: &str) -> anyhow::Result<()> {
191	let rows: Vec<JsonRow<'_>> = entries
192		.iter()
193		.map(|e| JsonRow {
194			package_moniker: render(&e.dep.package_moniker, scheme),
195			manifest_uri: &e.manifest_uri,
196			manifest_kind: e.manifest_kind.tag(),
197			name: &e.dep.name,
198			import_root: &e.dep.import_root,
199			version: e.dep.version.as_deref(),
200			dep_kind: &e.dep.dep_kind,
201		})
202		.collect();
203	serde_json::to_writer_pretty(&mut *w, &rows)?;
204	w.write_all(b"\n")?;
205	Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210	use super::*;
211	use std::fs;
212
213	fn args_for(path: PathBuf, format: ManifestFormat) -> ManifestArgs {
214		ManifestArgs {
215			path,
216			format,
217			count: false,
218			quiet: false,
219			scheme: None,
220		}
221	}
222
223	#[test]
224	fn single_file_emits_tsv_row_per_dep() {
225		let tmp = tempfile::tempdir().unwrap();
226		let p = tmp.path().join("package.json");
227		fs::write(
228			&p,
229			r#"{"name":"demo","version":"0.1.0","dependencies":{"react":"^18"}}"#,
230		)
231		.unwrap();
232		let args = args_for(p, ManifestFormat::Tsv);
233		let mut out = Vec::new();
234		let mut err = Vec::new();
235		assert_eq!(run(&args, &mut out, &mut err), 0);
236		let text = String::from_utf8(out).unwrap();
237		assert!(text.contains("code+moniker://./external_pkg:demo"));
238		assert!(text.contains("code+moniker://./external_pkg:react"));
239		assert!(text.contains("\treact\t"));
240		assert!(text.contains("\tnormal"));
241	}
242
243	#[test]
244	fn single_file_emits_json_array() {
245		let tmp = tempfile::tempdir().unwrap();
246		let p = tmp.path().join("Cargo.toml");
247		fs::write(
248			&p,
249			"[package]\nname=\"demo\"\nversion=\"0.1.0\"\n\n[dependencies]\nserde-json = \"1\"\n",
250		)
251		.unwrap();
252		let args = args_for(p, ManifestFormat::Json);
253		let mut out = Vec::new();
254		let mut err = Vec::new();
255		assert_eq!(run(&args, &mut out, &mut err), 0);
256		let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
257		let rows = v.as_array().expect("array");
258		assert!(rows.iter().any(|r| r["import_root"] == "serde_json"
259			&& r["package_moniker"] == "code+moniker://./external_pkg:serde_json"));
260		assert!(rows.iter().any(|r| r["manifest_kind"] == "cargo"));
261	}
262
263	#[test]
264	fn dir_mode_walks_every_manifest_kind() {
265		let tmp = tempfile::tempdir().unwrap();
266		let root = tmp.path();
267		fs::write(
268			root.join("package.json"),
269			r#"{"name":"a","dependencies":{"react":"^18"}}"#,
270		)
271		.unwrap();
272		fs::create_dir_all(root.join("sub")).unwrap();
273		fs::write(
274			root.join("sub/Cargo.toml"),
275			"[package]\nname=\"b\"\nversion=\"0\"\n\n[dependencies]\nserde = \"1\"\n",
276		)
277		.unwrap();
278		let args = args_for(root.to_path_buf(), ManifestFormat::Tsv);
279		let mut out = Vec::new();
280		let mut err = Vec::new();
281		assert_eq!(run(&args, &mut out, &mut err), 0);
282		let text = String::from_utf8(out).unwrap();
283		assert!(text.contains("\tpackage.json\t"));
284		assert!(text.contains("\tsub/Cargo.toml\t"));
285		assert!(text.contains("external_pkg:react"));
286		assert!(text.contains("external_pkg:serde"));
287	}
288
289	#[test]
290	fn unknown_filename_reports_usage_error() {
291		let tmp = tempfile::tempdir().unwrap();
292		let p = tmp.path().join("README.md");
293		fs::write(&p, "").unwrap();
294		let args = args_for(p, ManifestFormat::Tsv);
295		let mut out = Vec::new();
296		let mut err = Vec::new();
297		assert_eq!(run(&args, &mut out, &mut err), 2);
298		let err_text = String::from_utf8(err).unwrap();
299		assert!(err_text.contains("filename not recognised"));
300	}
301
302	#[test]
303	fn empty_dir_exits_with_no_match() {
304		let tmp = tempfile::tempdir().unwrap();
305		let args = args_for(tmp.path().to_path_buf(), ManifestFormat::Tsv);
306		let mut out = Vec::new();
307		let mut err = Vec::new();
308		assert_eq!(run(&args, &mut out, &mut err), 1);
309	}
310
311	#[test]
312	fn count_mode_prints_total_rows() {
313		let tmp = tempfile::tempdir().unwrap();
314		let p = tmp.path().join("package.json");
315		fs::write(&p, r#"{"name":"demo","dependencies":{"a":"1","b":"2"}}"#).unwrap();
316		let mut args = args_for(p, ManifestFormat::Tsv);
317		args.count = true;
318		let mut out = Vec::new();
319		let mut err = Vec::new();
320		assert_eq!(run(&args, &mut out, &mut err), 0);
321		assert_eq!(String::from_utf8(out).unwrap(), "3\n");
322	}
323
324	#[test]
325	fn quiet_mode_emits_nothing() {
326		let tmp = tempfile::tempdir().unwrap();
327		let p = tmp.path().join("package.json");
328		fs::write(&p, r#"{"name":"demo"}"#).unwrap();
329		let mut args = args_for(p, ManifestFormat::Tsv);
330		args.quiet = true;
331		let mut out = Vec::new();
332		let mut err = Vec::new();
333		assert_eq!(run(&args, &mut out, &mut err), 0);
334		assert!(out.is_empty());
335	}
336
337	#[cfg(feature = "pretty")]
338	#[test]
339	fn tree_format_groups_rows_by_manifest() {
340		let tmp = tempfile::tempdir().unwrap();
341		let p = tmp.path().join("package.json");
342		fs::write(
343			&p,
344			r#"{"name":"demo","dependencies":{"react":"^18"},"devDependencies":{"vitest":"1"}}"#,
345		)
346		.unwrap();
347		let args = args_for(p, ManifestFormat::Tree);
348		let mut out = Vec::new();
349		let mut err = Vec::new();
350		assert_eq!(run(&args, &mut out, &mut err), 0);
351		let text = String::from_utf8(out).unwrap();
352		assert!(text.contains("package.json\n"), "{text}");
353		assert!(text.contains("react"), "{text}");
354		assert!(text.contains("└─"), "{text}");
355		assert!(text.contains("(dev)"), "{text}");
356		assert!(
357			text.contains("code+moniker://./external_pkg:react"),
358			"{text}"
359		);
360	}
361
362	#[test]
363	fn custom_scheme_round_trips_in_tsv() {
364		let tmp = tempfile::tempdir().unwrap();
365		let p = tmp.path().join("package.json");
366		fs::write(&p, r#"{"name":"demo","dependencies":{"react":"^18"}}"#).unwrap();
367		let mut args = args_for(p, ManifestFormat::Tsv);
368		args.scheme = Some("acme://".into());
369		let mut out = Vec::new();
370		let mut err = Vec::new();
371		assert_eq!(run(&args, &mut out, &mut err), 0);
372		let text = String::from_utf8(out).unwrap();
373		assert!(text.contains("acme://./external_pkg:react"));
374	}
375}