1pub mod cli;
2
3use std::{
4 borrow::Cow,
5 collections::BTreeSet,
6 fs,
7 io::{self, IsTerminal},
8 path::{Component, Path, PathBuf},
9};
10
11use cli::{
12 CheckArgs, Cli, Command, ConfigSubcommand, DumpContextArgs, GenerateArgs, InitArgs, LocateArgs,
13 PrintArgs,
14};
15use numi_config::{
16 CONFIG_FILE_NAME, Config, LoadedManifest, Manifest, ManifestKindSniff, WorkspaceConfig,
17 WorkspaceMember, resolve_workspace_member_config, workspace_member_config_path,
18};
19
20const STARTER_CONFIG_FALLBACK: &str = include_str!("../assets/starter-numi.toml");
21const STATUS_LABEL_WIDTH: usize = 10;
22
23#[derive(Debug)]
24pub struct CliError {
25 message: String,
26 exit_code: i32,
27}
28
29impl CliError {
30 fn new(message: impl Into<String>) -> Self {
31 Self {
32 message: message.into(),
33 exit_code: 1,
34 }
35 }
36
37 fn with_exit_code(message: impl Into<String>, exit_code: i32) -> Self {
38 Self {
39 message: message.into(),
40 exit_code,
41 }
42 }
43
44 pub fn exit_code(&self) -> i32 {
45 self.exit_code
46 }
47}
48
49impl std::fmt::Display for CliError {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(f, "{}", self.message)
52 }
53}
54
55impl std::error::Error for CliError {}
56
57pub fn run(cli: Cli) -> Result<(), CliError> {
58 let command = cli
59 .command
60 .ok_or_else(|| CliError::new("a subcommand is required"))?;
61
62 match command {
63 Command::Generate(args) => run_generate(&args),
64 Command::Check(args) => run_check(&args),
65 Command::Init(args) => run_init(&args),
66 Command::Config(config) => match config.command {
67 ConfigSubcommand::Locate(args) => run_config_locate(&args),
68 ConfigSubcommand::Print(args) => run_config_print(&args),
69 },
70 Command::DumpContext(args) => run_dump_context(&args),
71 }
72}
73
74fn run_generate(args: &GenerateArgs) -> Result<(), CliError> {
75 let loaded = load_execution_manifest(args.config.as_deref(), args.workspace)?;
76 cli_ui().manifest(&loaded.manifest, &loaded.path);
77 match &loaded.manifest {
78 Manifest::Config(config) => run_generate_config(&loaded.path, config, args),
79 Manifest::Workspace(workspace) => run_generate_workspace(&loaded.path, workspace, args),
80 }
81}
82
83fn run_generate_config(
84 config_path: &Path,
85 _config: &Config,
86 args: &GenerateArgs,
87) -> Result<(), CliError> {
88 let selected_jobs = selected_jobs(&args.jobs);
89 let incremental = args.incremental_override.resolve();
90 let ui = cli_ui();
91 let report = numi_core::generate_with_options_and_progress(
92 config_path,
93 selected_jobs,
94 numi_core::GenerateOptions {
95 incremental: incremental.incremental,
96 parse_cache: incremental.parse_cache,
97 force_regenerate: incremental.force_regenerate,
98 workspace_manifest_path: None,
99 },
100 |progress| ui.progress(progress),
101 )
102 .map_err(|error| CliError::new(error.to_string()))?;
103 let output_root = manifest_dir(config_path)?;
104 ui.job_reports(output_root, &report.jobs);
105 print_warnings(&report.warnings);
106 let mut summary = JobSummary::default();
107 summary.record_jobs(&report.jobs);
108 ui.generation_summary(summary);
109 Ok(())
110}
111
112fn run_check(args: &CheckArgs) -> Result<(), CliError> {
113 let loaded = load_execution_manifest(args.config.as_deref(), args.workspace)?;
114 cli_ui().manifest(&loaded.manifest, &loaded.path);
115 match &loaded.manifest {
116 Manifest::Config(config) => run_check_config(&loaded.path, config, args),
117 Manifest::Workspace(workspace) => run_check_workspace(&loaded.path, workspace, args),
118 }
119}
120
121fn run_check_config(
122 config_path: &Path,
123 _config: &Config,
124 args: &CheckArgs,
125) -> Result<(), CliError> {
126 let selected_jobs = selected_jobs(&args.jobs);
127
128 let report = numi_core::check(config_path, selected_jobs)
129 .map_err(|error| CliError::new(error.to_string()))?;
130 print_warnings(&report.warnings);
131
132 if report.stale_paths.is_empty() {
133 cli_ui().status(
134 StatusTone::Success,
135 "Polished",
136 "generated outputs look fresh",
137 );
138 Ok(())
139 } else {
140 let lines = report
141 .stale_paths
142 .iter()
143 .map(display_path)
144 .collect::<Vec<_>>()
145 .join("\n");
146 Err(CliError::with_exit_code(
147 format!("stale generated outputs:\n{lines}"),
148 2,
149 ))
150 }
151}
152
153fn run_dump_context(args: &DumpContextArgs) -> Result<(), CliError> {
154 let loaded = load_cli_manifest(args.config.as_deref(), false)?;
155 match &loaded.manifest {
156 Manifest::Config(_) => {
157 let report = numi_core::dump_context(&loaded.path, &args.job)
158 .map_err(|error| CliError::new(error.to_string()))?;
159 print_warnings(&report.warnings);
160 println!("{}", report.json);
161 Ok(())
162 }
163 Manifest::Workspace(_) => Err(CliError::new(
164 "`dump-context` only supports single-config manifests; run it from a member directory or pass `--config <member>/numi.toml`",
165 )),
166 }
167}
168
169fn run_init(args: &InitArgs) -> Result<(), CliError> {
170 let cwd = current_dir()?;
171 let config_path = cwd.join(CONFIG_FILE_NAME);
172
173 if config_path.exists() && !args.force {
174 return Err(CliError::new(format!(
175 "{CONFIG_FILE_NAME} already exists; pass --force to overwrite"
176 )));
177 }
178
179 let starter_config = load_starter_config()?;
180 fs::write(&config_path, starter_config.as_ref()).map_err(|error| {
181 CliError::new(format!(
182 "failed to write starter config {}: {error}",
183 config_path.display()
184 ))
185 })?;
186 cli_ui().status(
187 StatusTone::Success,
188 "Stitched",
189 format!("starter {}", display_contextual_path(&config_path)),
190 );
191
192 Ok(())
193}
194
195fn run_generate_workspace(
196 manifest_path: &Path,
197 workspace: &WorkspaceConfig,
198 args: &GenerateArgs,
199) -> Result<(), CliError> {
200 let workspace_dir = manifest_dir(manifest_path)?;
201 let ui = cli_ui();
202 let mut summary = JobSummary::default();
203
204 for member in workspace.members() {
205 let member_root = workspace_member_root(&member);
206 let config_path = workspace_member_config_path(workspace_dir, &member_root);
207 let loaded_member = numi_config::load_unvalidated_from_path(&config_path)
208 .map_err(|error| CliError::new(error.to_string()))?;
209 let merged_config = resolve_workspace_member_config(
210 workspace_dir,
211 workspace,
212 &member_root,
213 &loaded_member.config,
214 )
215 .map_err(render_config_diagnostics)?;
216 let selected_jobs = workspace_jobs(args, &member);
217 let incremental = args.incremental_override.resolve();
218 let report = numi_core::generate_loaded_config_with_progress(
219 &config_path,
220 &merged_config,
221 selected_jobs.as_deref(),
222 numi_core::GenerateOptions {
223 incremental: incremental.incremental,
224 parse_cache: incremental.parse_cache,
225 force_regenerate: incremental.force_regenerate,
226 workspace_manifest_path: Some(manifest_path.to_path_buf()),
227 },
228 |progress| ui.progress(progress),
229 )
230 .map_err(|error| CliError::new(error.to_string()))?;
231 ui.job_reports(workspace_dir, &report.jobs);
232 print_warnings(&report.warnings);
233 summary.record_jobs(&report.jobs);
234 }
235
236 ui.generation_summary(summary);
237 Ok(())
238}
239
240fn run_check_workspace(
241 manifest_path: &Path,
242 workspace: &WorkspaceConfig,
243 args: &CheckArgs,
244) -> Result<(), CliError> {
245 let workspace_dir = manifest_dir(manifest_path)?;
246 let mut stale_paths = Vec::new();
247
248 for member in workspace.members() {
249 let member_root = workspace_member_root(&member);
250 let config_path = workspace_member_config_path(workspace_dir, &member_root);
251 let loaded_member = numi_config::load_unvalidated_from_path(&config_path)
252 .map_err(|error| CliError::new(error.to_string()))?;
253 let merged_config = resolve_workspace_member_config(
254 workspace_dir,
255 workspace,
256 &member_root,
257 &loaded_member.config,
258 )
259 .map_err(render_config_diagnostics)?;
260 let selected_jobs = workspace_jobs(args, &member);
261 let report = numi_core::check_loaded_config_with_options(
262 &config_path,
263 &merged_config,
264 selected_jobs.as_deref(),
265 numi_core::CheckOptions {
266 workspace_manifest_path: Some(manifest_path.to_path_buf()),
267 },
268 )
269 .map_err(|error| CliError::new(error.to_string()))?;
270 print_warnings(&report.warnings);
271 stale_paths.extend(
272 report
273 .stale_paths
274 .iter()
275 .map(|path| normalize_workspace_stale_path(path.as_std_path(), workspace_dir)),
276 );
277 }
278
279 if stale_paths.is_empty() {
280 cli_ui().status(
281 StatusTone::Success,
282 "Polished",
283 "workspace outputs look fresh",
284 );
285 Ok(())
286 } else {
287 let lines = stale_paths
288 .iter()
289 .map(display_path)
290 .collect::<Vec<_>>()
291 .join("\n");
292 Err(CliError::with_exit_code(
293 format!("stale generated outputs:\n{lines}"),
294 2,
295 ))
296 }
297}
298
299fn run_config_locate(args: &LocateArgs) -> Result<(), CliError> {
300 let config_path = discover_config_path(args.config.as_deref())?;
301 println!("{}", display_path(&config_path));
302 Ok(())
303}
304
305fn run_config_print(args: &PrintArgs) -> Result<(), CliError> {
306 let loaded = load_cli_manifest(args.config.as_deref(), false)?;
307 match &loaded.manifest {
308 Manifest::Config(config) => {
309 let resolved = numi_config::resolve_config(config);
310 let rendered = toml::to_string_pretty(&resolved).map_err(|error| {
311 CliError::new(format!("failed to serialize config TOML: {error}"))
312 })?;
313 print!("{rendered}");
314 Ok(())
315 }
316 Manifest::Workspace(_) => Err(CliError::new(
317 "`config print` only supports single-config manifests; run it from a member directory or pass `--config <member>/numi.toml`",
318 )),
319 }
320}
321
322fn load_cli_manifest(
323 explicit_path: Option<&Path>,
324 workspace: bool,
325) -> Result<LoadedManifest, CliError> {
326 if workspace {
327 return load_workspace_cli_manifest(explicit_path);
328 }
329
330 let cwd = current_dir()?;
331 let manifest_path = numi_config::discover_config(&cwd, explicit_path)
332 .map_err(|error| CliError::new(error.to_string()))?;
333
334 numi_config::load_manifest_from_path(&manifest_path)
335 .map_err(|error| CliError::new(error.to_string()))
336}
337
338fn load_execution_manifest(
339 explicit_path: Option<&Path>,
340 workspace: bool,
341) -> Result<LoadedManifest, CliError> {
342 if workspace || explicit_path.is_some() {
343 return load_cli_manifest(explicit_path, workspace);
344 }
345
346 let cwd = current_dir()?;
347 let manifest_path = numi_config::discover_config(&cwd, None)
348 .map_err(|error| CliError::new(error.to_string()))?;
349 let manifest_kind =
350 numi_config::sniff_manifest_kind_from_path(&manifest_path).map_err(|error| {
351 CliError::new(format!(
352 "failed to read manifest {}: {error}",
353 manifest_path.display()
354 ))
355 })?;
356
357 if matches!(manifest_kind, ManifestKindSniff::ConfigLike)
358 && let Ok(workspace_loaded) = load_workspace_cli_manifest(None)
359 && workspace_loaded.path != manifest_path
360 {
361 return Ok(workspace_loaded);
362 }
363
364 numi_config::load_manifest_from_path(&manifest_path)
365 .map_err(|error| CliError::new(error.to_string()))
366}
367
368fn load_workspace_cli_manifest(explicit_path: Option<&Path>) -> Result<LoadedManifest, CliError> {
369 let cwd = current_dir()?;
370
371 if let Some(explicit_path) = explicit_path {
372 let manifest_path = numi_config::discover_workspace_ancestor(&cwd, Some(explicit_path))
373 .map_err(workspace_manifest_discovery_error)?;
374 return load_workspace_manifest_candidate(&manifest_path);
375 }
376
377 let canonical_cwd = cwd
378 .canonicalize()
379 .map_err(|error| CliError::new(format!("failed to read cwd: {error}")))?;
380
381 for directory in canonical_cwd.ancestors() {
382 let candidate = directory.join(CONFIG_FILE_NAME);
383 if !candidate.is_file() {
384 continue;
385 }
386
387 match numi_config::sniff_manifest_kind_from_path(&candidate).map_err(|error| {
388 CliError::new(format!(
389 "failed to read manifest {}: {error}",
390 candidate.display()
391 ))
392 })? {
393 ManifestKindSniff::WorkspaceLike
394 | ManifestKindSniff::BrokenWorkspaceLike
395 | ManifestKindSniff::Mixed => {
396 return load_workspace_manifest_candidate(&candidate);
397 }
398 ManifestKindSniff::ConfigLike
399 | ManifestKindSniff::Unknown
400 | ManifestKindSniff::Unparsable => continue,
401 }
402 }
403
404 Err(workspace_manifest_discovery_error(
405 numi_config::DiscoveryError::NotFound {
406 start_dir: canonical_cwd,
407 },
408 ))
409}
410
411fn require_workspace_manifest(loaded: LoadedManifest) -> Result<LoadedManifest, CliError> {
412 match loaded.manifest {
413 Manifest::Workspace(_) => Ok(loaded),
414 Manifest::Config(_) => Err(CliError::new(format!(
415 "expected a workspace manifest at {}; pass --config <workspace>/numi.toml or remove --workspace",
416 loaded.path.display()
417 ))),
418 }
419}
420
421fn load_workspace_manifest_candidate(path: &Path) -> Result<LoadedManifest, CliError> {
422 let loaded = numi_config::load_manifest_from_path(path).map_err(|error| {
423 CliError::new(format!(
424 "failed to load workspace manifest {}: {error}",
425 path.display()
426 ))
427 })?;
428 require_workspace_manifest(loaded)
429}
430
431fn workspace_manifest_discovery_error(error: numi_config::DiscoveryError) -> CliError {
432 match error {
433 numi_config::DiscoveryError::ExplicitPathNotFound(path) => CliError::new(format!(
434 "workspace manifest not found: {}\n\npass --config <workspace>/numi.toml or remove --workspace",
435 path.display()
436 )),
437 numi_config::DiscoveryError::NotFound { start_dir } => CliError::new(format!(
438 "No workspace manifest found from {}\n\nRun this from a workspace member directory with an ancestor numi.toml, or pass --config <workspace>/numi.toml",
439 start_dir.display()
440 )),
441 numi_config::DiscoveryError::Ambiguous { root, matches } => {
442 let lines = matches
443 .iter()
444 .map(|path| format!(" - {}", path.display()))
445 .collect::<Vec<_>>()
446 .join("\n");
447 CliError::new(format!(
448 "Multiple workspace manifests found under {}:\n{}\n\npass --config <workspace>/numi.toml",
449 root.display(),
450 lines
451 ))
452 }
453 numi_config::DiscoveryError::Io(error) => CliError::new(error.to_string()),
454 }
455}
456
457fn current_dir() -> Result<PathBuf, CliError> {
458 std::env::current_dir().map_err(|error| CliError::new(format!("failed to read cwd: {error}")))
459}
460
461fn load_starter_config() -> Result<Cow<'static, str>, CliError> {
462 Ok(Cow::Borrowed(STARTER_CONFIG_FALLBACK))
463}
464
465fn manifest_dir(manifest_path: &Path) -> Result<&Path, CliError> {
466 manifest_path
467 .parent()
468 .filter(|path| !path.as_os_str().is_empty())
469 .ok_or_else(|| {
470 CliError::new(format!(
471 "manifest {} has no parent directory",
472 manifest_path.display()
473 ))
474 })
475}
476
477fn discover_config_path(explicit_path: Option<&Path>) -> Result<PathBuf, CliError> {
478 let cwd = current_dir()?;
479 numi_config::discover_config(&cwd, explicit_path)
480 .map_err(|error| CliError::new(error.to_string()))
481}
482
483fn selected_jobs(jobs: &[String]) -> Option<&[String]> {
484 (!jobs.is_empty()).then_some(jobs)
485}
486
487fn workspace_member_root(member: &WorkspaceMember) -> String {
488 Path::new(&member.config)
489 .parent()
490 .filter(|path| !path.as_os_str().is_empty())
491 .map(display_path)
492 .unwrap_or_else(|| String::from("."))
493}
494
495fn workspace_member_jobs(member: &WorkspaceMember) -> Option<&[String]> {
496 (!member.jobs.is_empty()).then_some(member.jobs.as_slice())
497}
498
499fn workspace_jobs<T>(args: &T, member: &WorkspaceMember) -> Option<Vec<String>>
500where
501 T: WorkspaceJobArgs,
502{
503 match (args.selected_jobs(), workspace_member_jobs(member)) {
504 (None, None) => None,
505 (Some(cli_jobs), None) => Some(cli_jobs.to_vec()),
506 (None, Some(member_jobs)) => Some(member_jobs.to_vec()),
507 (Some(cli_jobs), Some(member_jobs)) => {
508 let allowed_jobs = member_jobs
509 .iter()
510 .map(String::as_str)
511 .collect::<BTreeSet<_>>();
512 Some(
513 cli_jobs
514 .iter()
515 .filter(|job| allowed_jobs.contains(job.as_str()))
516 .cloned()
517 .collect(),
518 )
519 }
520 }
521}
522
523fn normalize_workspace_stale_path(path: &Path, workspace_dir: &Path) -> PathBuf {
524 path.strip_prefix(workspace_dir)
525 .map(Path::to_path_buf)
526 .unwrap_or_else(|_| path.to_path_buf())
527}
528
529fn print_warnings<T: std::fmt::Display>(warnings: &[T]) {
530 for warning in warnings {
531 cli_ui().warning(&warning.to_string());
532 }
533}
534
535fn render_config_diagnostics<I, T>(diagnostics: I) -> CliError
536where
537 I: IntoIterator<Item = T>,
538 T: std::fmt::Display,
539{
540 let message = diagnostics
541 .into_iter()
542 .map(|diagnostic| diagnostic.to_string())
543 .collect::<Vec<_>>()
544 .join("\n");
545 CliError::new(message)
546}
547
548fn display_path(path: impl AsRef<Path>) -> String {
549 path.as_ref().to_string_lossy().into_owned()
550}
551
552trait WorkspaceJobArgs {
553 fn selected_jobs(&self) -> Option<&[String]>;
554}
555
556impl WorkspaceJobArgs for GenerateArgs {
557 fn selected_jobs(&self) -> Option<&[String]> {
558 selected_jobs(&self.jobs)
559 }
560}
561
562impl WorkspaceJobArgs for CheckArgs {
563 fn selected_jobs(&self) -> Option<&[String]> {
564 selected_jobs(&self.jobs)
565 }
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569enum StatusTone {
570 Accent,
571 Success,
572 Warning,
573 Error,
574}
575
576#[derive(Debug, Clone, Copy)]
577struct CliUi {
578 interactive: bool,
579 color: bool,
580}
581
582impl CliUi {
583 fn stderr() -> Self {
584 let interactive = io::stderr().is_terminal();
585 let color = interactive && std::env::var_os("NO_COLOR").is_none();
586 Self { interactive, color }
587 }
588
589 fn manifest(&self, manifest: &Manifest, path: &Path) {
590 let kind = match manifest {
591 Manifest::Config(_) => "config",
592 Manifest::Workspace(_) => "workspace",
593 };
594 self.status(
595 StatusTone::Accent,
596 "Summoning",
597 format!("{kind} {}", display_contextual_path(path)),
598 );
599 }
600
601 fn progress(&self, progress: &numi_core::GenerateProgress) {
602 match progress {
603 numi_core::GenerateProgress::JobStarted { job_name } => {
604 let (label, tone, message) = job_started_status(job_name);
605 self.status(tone, label, message);
606 }
607 }
608 }
609
610 fn job_reports(&self, root: &Path, jobs: &[numi_core::JobReport]) {
611 for job in jobs {
612 for hook in &job.hook_reports {
613 let (label, tone, message) = hook_status(&job.job_name, hook);
614 self.status(tone, label, message);
615 }
616
617 let (label, tone) = match job.outcome {
618 numi_core::WriteOutcome::Created => ("Stitched", StatusTone::Success),
619 numi_core::WriteOutcome::Updated => ("Restitched", StatusTone::Success),
620 numi_core::WriteOutcome::Unchanged => ("Keeping", StatusTone::Accent),
621 numi_core::WriteOutcome::Skipped => ("Skipping", StatusTone::Warning),
622 };
623 let output_path = display_relative_path(root, job.output_path.as_std_path());
624 self.status(tone, label, format!("{} -> {}", job.job_name, output_path));
625 }
626 }
627
628 fn generation_summary(&self, summary: JobSummary) {
629 if summary.total == 0 {
630 self.status(StatusTone::Accent, "Keeping", "no jobs were selected");
631 return;
632 }
633
634 let mut parts = Vec::new();
635 if summary.created > 0 {
636 parts.push(format!("{} stitched", summary.created));
637 }
638 if summary.updated > 0 {
639 parts.push(format!("{} re-stitched", summary.updated));
640 }
641 if summary.unchanged > 0 {
642 parts.push(format!("{} kept", summary.unchanged));
643 }
644 if summary.skipped > 0 {
645 parts.push(format!("{} skipped", summary.skipped));
646 }
647
648 let message = if parts.is_empty() {
649 format!("{} jobs settled", summary.total)
650 } else {
651 format!("{} jobs settled ({})", summary.total, parts.join(", "))
652 };
653 self.status(StatusTone::Success, "Polished", message);
654 }
655
656 fn warning(&self, message: &str) {
657 let message = rewrite_diagnostic_paths_in_cwd(message);
658 if self.interactive {
659 let body = message.strip_prefix("warning: ").unwrap_or(&message);
660 self.block(StatusTone::Warning, "Noted", body);
661 } else {
662 eprintln!("{message}");
663 }
664 }
665
666 fn error(&self, message: &str) {
667 let message = rewrite_diagnostic_paths_in_cwd(message);
668 if self.interactive {
669 self.block(StatusTone::Error, "Oops", &message);
670 } else {
671 eprintln!("{message}");
672 }
673 }
674
675 fn status(&self, tone: StatusTone, label: &str, message: impl AsRef<str>) {
676 if !self.interactive {
677 return;
678 }
679 self.block(tone, label, message.as_ref());
680 }
681
682 fn block(&self, tone: StatusTone, label: &str, message: &str) {
683 let rendered = format_status_block(label, tone, message, self.color);
684 eprint!("{rendered}");
685 }
686}
687
688fn hook_status(job_name: &str, hook: &numi_core::HookReport) -> (&'static str, StatusTone, String) {
689 let label = match hook.phase {
690 numi_core::HookPhase::PreGenerate => "Preparing",
691 numi_core::HookPhase::PostGenerate => "Tidying",
692 };
693
694 let message = if hook.command.is_empty() {
695 format!("{job_name} hook")
696 } else {
697 format!("{job_name} hook -> {}", render_hook_command(&hook.command))
698 };
699
700 (label, StatusTone::Accent, message)
701}
702
703fn job_started_status(job_name: &str) -> (&'static str, StatusTone, String) {
704 ("Weaving", StatusTone::Accent, format!("{job_name}..."))
705}
706
707fn render_hook_command(command: &[String]) -> String {
708 command.join(" ")
709}
710
711fn rewrite_diagnostic_paths_in_cwd(message: &str) -> String {
712 std::env::current_dir()
713 .ok()
714 .map(|cwd| rewrite_diagnostic_paths(message, &cwd))
715 .unwrap_or_else(|| message.to_string())
716}
717
718fn rewrite_diagnostic_paths(message: &str, cwd: &Path) -> String {
719 let mut rewritten = String::with_capacity(message.len());
720 let mut remaining = message;
721
722 while let Some(marker_index) = remaining.find("[path: ") {
723 let (prefix, after_prefix) = remaining.split_at(marker_index);
724 rewritten.push_str(prefix);
725
726 let after_marker = &after_prefix["[path: ".len()..];
727 let Some(path_end) = after_marker.find(']') else {
728 rewritten.push_str(after_prefix);
729 return rewritten;
730 };
731
732 let (path_text, suffix) = after_marker.split_at(path_end);
733 rewritten.push_str("[path: ");
734 rewritten.push_str(&rewrite_diagnostic_path(path_text, cwd));
735 rewritten.push(']');
736 remaining = &suffix[1..];
737 }
738
739 rewritten.push_str(remaining);
740 rewritten
741}
742
743fn rewrite_diagnostic_path(path_text: &str, cwd: &Path) -> String {
744 Path::new(path_text)
745 .strip_prefix(cwd)
746 .map(display_path)
747 .unwrap_or_else(|_| path_text.to_string())
748}
749
750fn cli_ui() -> CliUi {
751 CliUi::stderr()
752}
753
754#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
755struct JobSummary {
756 total: usize,
757 created: usize,
758 updated: usize,
759 unchanged: usize,
760 skipped: usize,
761}
762
763impl JobSummary {
764 fn record_jobs(&mut self, jobs: &[numi_core::JobReport]) {
765 for job in jobs {
766 self.record_outcome(job.outcome);
767 }
768 }
769
770 fn record_outcome(&mut self, outcome: numi_core::WriteOutcome) {
771 self.total += 1;
772 match outcome {
773 numi_core::WriteOutcome::Created => self.created += 1,
774 numi_core::WriteOutcome::Updated => self.updated += 1,
775 numi_core::WriteOutcome::Unchanged => self.unchanged += 1,
776 numi_core::WriteOutcome::Skipped => self.skipped += 1,
777 }
778 }
779}
780
781fn format_status_block(label: &str, tone: StatusTone, message: &str, color: bool) -> String {
782 let padded_label = format!("{label:>width$}", width = STATUS_LABEL_WIDTH);
783 let rendered_label = format_status_label(&padded_label, tone, color);
784 let continuation = " ".repeat(STATUS_LABEL_WIDTH);
785 let mut lines = message.lines();
786 let mut rendered = String::new();
787
788 if let Some(first_line) = lines.next() {
789 rendered.push_str(&format!("{rendered_label} {first_line}\n"));
790 } else {
791 rendered.push_str(&format!("{rendered_label}\n"));
792 }
793
794 for line in lines {
795 rendered.push_str(&format!("{continuation} {line}\n"));
796 }
797
798 rendered
799}
800
801fn format_status_label(label: &str, tone: StatusTone, color: bool) -> String {
802 if !color {
803 return label.to_string();
804 }
805
806 let code = match tone {
807 StatusTone::Accent => "36",
808 StatusTone::Success => "32",
809 StatusTone::Warning => "33",
810 StatusTone::Error => "31",
811 };
812 format!("\x1b[{code};1m{label}\x1b[0m")
813}
814
815fn display_relative_path(root: &Path, path: &Path) -> String {
816 path.strip_prefix(root)
817 .unwrap_or(path)
818 .to_string_lossy()
819 .into_owned()
820}
821
822fn display_contextual_path(path: &Path) -> String {
823 let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
824
825 if let Ok(cwd) = std::env::current_dir() {
826 let absolute_cwd = cwd.canonicalize().unwrap_or(cwd);
827 if let Some(relative) = lexical_relative_path(&absolute_path, &absolute_cwd) {
828 return display_path(relative);
829 }
830 }
831
832 display_path(absolute_path)
833}
834
835fn lexical_relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
836 let path_components = path.components().collect::<Vec<_>>();
837 let base_components = base.components().collect::<Vec<_>>();
838
839 let mut common_len = 0;
840 while common_len < path_components.len()
841 && common_len < base_components.len()
842 && path_components[common_len] == base_components[common_len]
843 {
844 common_len += 1;
845 }
846
847 if common_len == 0 {
848 return None;
849 }
850
851 let mut relative = PathBuf::new();
852 for component in &base_components[common_len..] {
853 match component {
854 Component::Normal(_) => relative.push(".."),
855 Component::CurDir => {}
856 Component::ParentDir => relative.push(".."),
857 Component::RootDir | Component::Prefix(_) => return None,
858 }
859 }
860
861 for component in &path_components[common_len..] {
862 relative.push(component.as_os_str());
863 }
864
865 if relative.as_os_str().is_empty() {
866 relative.push(".");
867 }
868
869 Some(relative)
870}
871
872pub fn print_error(error: &CliError) {
873 cli_ui().error(&error.message);
874}
875
876#[cfg(test)]
877mod cli_ui_tests {
878 use super::*;
879
880 #[test]
881 fn format_status_block_renders_single_line_plain() {
882 let rendered = format_status_block(
883 "Summoning",
884 StatusTone::Accent,
885 "workspace numi.toml",
886 false,
887 );
888 assert_eq!(rendered, " Summoning workspace numi.toml\n");
889 }
890
891 #[test]
892 fn format_status_block_indents_multiline_messages() {
893 let rendered =
894 format_status_block("Oops", StatusTone::Error, "first line\nsecond line", false);
895 assert_eq!(rendered, " Oops first line\n second line\n");
896 }
897
898 #[test]
899 fn format_status_label_wraps_color_when_enabled() {
900 let rendered = format_status_label("Stitched", StatusTone::Success, true);
901 assert!(rendered.starts_with("\u{1b}[32;1m"));
902 assert!(rendered.ends_with("\u{1b}[0m"));
903 assert!(rendered.contains("Stitched"));
904 }
905
906 #[test]
907 fn generation_summary_reports_breakdown() {
908 let mut summary = JobSummary::default();
909 summary.record_outcome(numi_core::WriteOutcome::Created);
910 summary.record_outcome(numi_core::WriteOutcome::Unchanged);
911 summary.record_outcome(numi_core::WriteOutcome::Skipped);
912 let rendered = format_status_block(
913 "Polished",
914 StatusTone::Success,
915 "3 jobs settled (1 stitched, 1 kept, 1 skipped)",
916 false,
917 );
918
919 assert_eq!(
920 rendered,
921 " Polished 3 jobs settled (1 stitched, 1 kept, 1 skipped)\n"
922 );
923 assert_eq!(
924 summary,
925 JobSummary {
926 total: 3,
927 created: 1,
928 updated: 0,
929 unchanged: 1,
930 skipped: 1,
931 }
932 );
933 }
934
935 #[test]
936 fn hook_status_message_includes_configured_command() {
937 let hook = numi_core::HookReport {
938 phase: numi_core::HookPhase::PostGenerate,
939 command: vec!["utils/numi-post-generate-format.sh".to_string()],
940 };
941
942 let (label, tone, message) = hook_status("assets", &hook);
943
944 assert_eq!(label, "Tidying");
945 assert_eq!(tone, StatusTone::Accent);
946 assert_eq!(message, "assets hook -> utils/numi-post-generate-format.sh");
947 }
948
949 #[test]
950 fn hook_status_message_falls_back_to_hook_name_when_command_is_empty() {
951 let hook = numi_core::HookReport {
952 phase: numi_core::HookPhase::PreGenerate,
953 command: Vec::new(),
954 };
955
956 let (label, tone, message) = hook_status("files", &hook);
957
958 assert_eq!(label, "Preparing");
959 assert_eq!(tone, StatusTone::Accent);
960 assert_eq!(message, "files hook");
961 }
962
963 #[test]
964 fn job_started_status_message_describes_current_job() {
965 let (label, tone, message) = job_started_status("assets");
966
967 assert_eq!(label, "Weaving");
968 assert_eq!(tone, StatusTone::Accent);
969 assert_eq!(message, "assets...");
970 }
971
972 #[test]
973 fn rewrite_diagnostic_paths_relativizes_paths_under_cwd() {
974 let cwd = Path::new("/tmp/workspace");
975 let message =
976 "warning: skipped entry [path: /tmp/workspace/AppUI/Resources/Localizable.xcstrings]";
977
978 let rewritten = rewrite_diagnostic_paths(message, cwd);
979
980 assert_eq!(
981 rewritten,
982 "warning: skipped entry [path: AppUI/Resources/Localizable.xcstrings]"
983 );
984 }
985
986 #[test]
987 fn rewrite_diagnostic_paths_keeps_paths_outside_cwd() {
988 let cwd = Path::new("/tmp/workspace");
989 let message = "warning: skipped entry [path: /tmp/other/Localizable.xcstrings]";
990
991 let rewritten = rewrite_diagnostic_paths(message, cwd);
992
993 assert_eq!(
994 rewritten,
995 "warning: skipped entry [path: /tmp/other/Localizable.xcstrings]"
996 );
997 }
998
999 #[test]
1000 fn lexical_relative_path_walks_up_to_workspace_manifest() {
1001 let path = Path::new("/tmp/workspace/numi.toml");
1002 let base = Path::new("/tmp/workspace/AppUI");
1003
1004 let relative = lexical_relative_path(path, base).expect("relative path should resolve");
1005
1006 assert_eq!(relative, PathBuf::from("../numi.toml"));
1007 }
1008}