Skip to main content

numi_cli/
lib.rs

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}