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}