Skip to main content

numi_cli/
lib.rs

1pub mod cli;
2
3use std::{
4    borrow::Cow,
5    collections::BTreeSet,
6    fs,
7    path::{Path, PathBuf},
8};
9
10use cli::{
11    CheckArgs, Cli, Command, ConfigSubcommand, DumpContextArgs, GenerateArgs, InitArgs, LocateArgs,
12    PrintArgs,
13};
14use numi_config::{
15    CONFIG_FILE_NAME, Config, LoadedManifest, Manifest, ManifestKindSniff, WorkspaceConfig,
16    WorkspaceMember, resolve_workspace_member_config, workspace_member_config_path,
17};
18
19const STARTER_CONFIG_FALLBACK: &str = include_str!("../assets/starter-numi.toml");
20
21#[derive(Debug)]
22pub struct CliError {
23    message: String,
24    exit_code: i32,
25}
26
27impl CliError {
28    fn new(message: impl Into<String>) -> Self {
29        Self {
30            message: message.into(),
31            exit_code: 1,
32        }
33    }
34
35    fn with_exit_code(message: impl Into<String>, exit_code: i32) -> Self {
36        Self {
37            message: message.into(),
38            exit_code,
39        }
40    }
41
42    pub fn exit_code(&self) -> i32 {
43        self.exit_code
44    }
45}
46
47impl std::fmt::Display for CliError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "{}", self.message)
50    }
51}
52
53impl std::error::Error for CliError {}
54
55pub fn run(cli: Cli) -> Result<(), CliError> {
56    let command = cli
57        .command
58        .ok_or_else(|| CliError::new("a subcommand is required"))?;
59
60    match command {
61        Command::Generate(args) => run_generate(&args),
62        Command::Check(args) => run_check(&args),
63        Command::Init(args) => run_init(&args),
64        Command::Config(config) => match config.command {
65            ConfigSubcommand::Locate(args) => run_config_locate(&args),
66            ConfigSubcommand::Print(args) => run_config_print(&args),
67        },
68        Command::DumpContext(args) => run_dump_context(&args),
69    }
70}
71
72fn run_generate(args: &GenerateArgs) -> Result<(), CliError> {
73    let loaded = load_cli_manifest(args.config.as_deref(), args.workspace)?;
74    match &loaded.manifest {
75        Manifest::Config(config) => run_generate_config(&loaded.path, config, args),
76        Manifest::Workspace(workspace) => run_generate_workspace(&loaded.path, workspace, args),
77    }
78}
79
80fn run_generate_config(
81    config_path: &Path,
82    _config: &Config,
83    args: &GenerateArgs,
84) -> Result<(), CliError> {
85    let selected_jobs = selected_jobs(&args.jobs);
86    let report = numi_core::generate_with_options(
87        config_path,
88        selected_jobs,
89        numi_core::GenerateOptions {
90            incremental: args.incremental_override.resolve(),
91        },
92    )
93    .map_err(|error| CliError::new(error.to_string()))?;
94    print_warnings(&report.warnings);
95    Ok(())
96}
97
98fn run_check(args: &CheckArgs) -> Result<(), CliError> {
99    let loaded = load_cli_manifest(args.config.as_deref(), args.workspace)?;
100    match &loaded.manifest {
101        Manifest::Config(config) => run_check_config(&loaded.path, config, args),
102        Manifest::Workspace(workspace) => run_check_workspace(&loaded.path, workspace, args),
103    }
104}
105
106fn run_check_config(
107    config_path: &Path,
108    _config: &Config,
109    args: &CheckArgs,
110) -> Result<(), CliError> {
111    let selected_jobs = selected_jobs(&args.jobs);
112
113    let report = numi_core::check(config_path, selected_jobs)
114        .map_err(|error| CliError::new(error.to_string()))?;
115    print_warnings(&report.warnings);
116
117    if report.stale_paths.is_empty() {
118        Ok(())
119    } else {
120        let lines = report
121            .stale_paths
122            .iter()
123            .map(display_path)
124            .collect::<Vec<_>>()
125            .join("\n");
126        Err(CliError::with_exit_code(
127            format!("stale generated outputs:\n{lines}"),
128            2,
129        ))
130    }
131}
132
133fn run_dump_context(args: &DumpContextArgs) -> Result<(), CliError> {
134    let loaded = load_cli_manifest(args.config.as_deref(), false)?;
135    match &loaded.manifest {
136        Manifest::Config(_) => {
137            let report = numi_core::dump_context(&loaded.path, &args.job)
138                .map_err(|error| CliError::new(error.to_string()))?;
139            print_warnings(&report.warnings);
140            println!("{}", report.json);
141            Ok(())
142        }
143        Manifest::Workspace(_) => Err(CliError::new(
144            "`dump-context` only supports single-config manifests; run it from a member directory or pass `--config <member>/numi.toml`",
145        )),
146    }
147}
148
149fn run_init(args: &InitArgs) -> Result<(), CliError> {
150    let cwd = current_dir()?;
151    let config_path = cwd.join(CONFIG_FILE_NAME);
152
153    if config_path.exists() && !args.force {
154        return Err(CliError::new(format!(
155            "{CONFIG_FILE_NAME} already exists; pass --force to overwrite"
156        )));
157    }
158
159    let starter_config = load_starter_config()?;
160    fs::write(&config_path, starter_config.as_ref()).map_err(|error| {
161        CliError::new(format!(
162            "failed to write starter config {}: {error}",
163            config_path.display()
164        ))
165    })?;
166
167    Ok(())
168}
169
170fn run_generate_workspace(
171    manifest_path: &Path,
172    workspace: &WorkspaceConfig,
173    args: &GenerateArgs,
174) -> Result<(), CliError> {
175    let workspace_dir = manifest_dir(manifest_path)?;
176
177    for member in workspace.members() {
178        let member_root = workspace_member_root(&member);
179        let config_path = workspace_member_config_path(workspace_dir, &member_root);
180        let loaded_member = numi_config::load_unvalidated_from_path(&config_path)
181            .map_err(|error| CliError::new(error.to_string()))?;
182        let merged_config =
183            resolve_workspace_member_config(workspace, &member_root, &loaded_member.config)
184                .map_err(render_config_diagnostics)?;
185        let selected_jobs = workspace_jobs(args, &member);
186        let report = numi_core::generate_loaded_config(
187            &config_path,
188            &merged_config,
189            selected_jobs.as_deref(),
190            numi_core::GenerateOptions {
191                incremental: args.incremental_override.resolve(),
192            },
193        )
194        .map_err(|error| CliError::new(error.to_string()))?;
195        print_warnings(&report.warnings);
196    }
197
198    Ok(())
199}
200
201fn run_check_workspace(
202    manifest_path: &Path,
203    workspace: &WorkspaceConfig,
204    args: &CheckArgs,
205) -> Result<(), CliError> {
206    let workspace_dir = manifest_dir(manifest_path)?;
207    let mut stale_paths = Vec::new();
208
209    for member in workspace.members() {
210        let member_root = workspace_member_root(&member);
211        let config_path = workspace_member_config_path(workspace_dir, &member_root);
212        let loaded_member = numi_config::load_unvalidated_from_path(&config_path)
213            .map_err(|error| CliError::new(error.to_string()))?;
214        let merged_config =
215            resolve_workspace_member_config(workspace, &member_root, &loaded_member.config)
216                .map_err(render_config_diagnostics)?;
217        let selected_jobs = workspace_jobs(args, &member);
218        let report =
219            numi_core::check_loaded_config(&config_path, &merged_config, selected_jobs.as_deref())
220                .map_err(|error| CliError::new(error.to_string()))?;
221        print_warnings(&report.warnings);
222        stale_paths.extend(
223            report
224                .stale_paths
225                .iter()
226                .map(|path| normalize_workspace_stale_path(path.as_std_path(), workspace_dir)),
227        );
228    }
229
230    if stale_paths.is_empty() {
231        Ok(())
232    } else {
233        let lines = stale_paths
234            .iter()
235            .map(display_path)
236            .collect::<Vec<_>>()
237            .join("\n");
238        Err(CliError::with_exit_code(
239            format!("stale generated outputs:\n{lines}"),
240            2,
241        ))
242    }
243}
244
245fn run_config_locate(args: &LocateArgs) -> Result<(), CliError> {
246    let config_path = discover_config_path(args.config.as_deref())?;
247    println!("{}", display_path(&config_path));
248    Ok(())
249}
250
251fn run_config_print(args: &PrintArgs) -> Result<(), CliError> {
252    let loaded = load_cli_manifest(args.config.as_deref(), false)?;
253    match &loaded.manifest {
254        Manifest::Config(config) => {
255            let resolved = numi_config::resolve_config(config);
256            let rendered = toml::to_string_pretty(&resolved).map_err(|error| {
257                CliError::new(format!("failed to serialize config TOML: {error}"))
258            })?;
259            print!("{rendered}");
260            Ok(())
261        }
262        Manifest::Workspace(_) => Err(CliError::new(
263            "`config print` only supports single-config manifests; run it from a member directory or pass `--config <member>/numi.toml`",
264        )),
265    }
266}
267
268fn load_cli_manifest(
269    explicit_path: Option<&Path>,
270    workspace: bool,
271) -> Result<LoadedManifest, CliError> {
272    if workspace {
273        return load_workspace_cli_manifest(explicit_path);
274    }
275
276    let cwd = current_dir()?;
277    let manifest_path = numi_config::discover_config(&cwd, explicit_path)
278        .map_err(|error| CliError::new(error.to_string()))?;
279
280    numi_config::load_manifest_from_path(&manifest_path)
281        .map_err(|error| CliError::new(error.to_string()))
282}
283
284fn load_workspace_cli_manifest(explicit_path: Option<&Path>) -> Result<LoadedManifest, CliError> {
285    let cwd = current_dir()?;
286
287    if let Some(explicit_path) = explicit_path {
288        let manifest_path = numi_config::discover_workspace_ancestor(&cwd, Some(explicit_path))
289            .map_err(workspace_manifest_discovery_error)?;
290        return load_workspace_manifest_candidate(&manifest_path);
291    }
292
293    let canonical_cwd = cwd
294        .canonicalize()
295        .map_err(|error| CliError::new(format!("failed to read cwd: {error}")))?;
296
297    for directory in canonical_cwd.ancestors() {
298        let candidate = directory.join(CONFIG_FILE_NAME);
299        if !candidate.is_file() {
300            continue;
301        }
302
303        match numi_config::sniff_manifest_kind_from_path(&candidate).map_err(|error| {
304            CliError::new(format!(
305                "failed to read manifest {}: {error}",
306                candidate.display()
307            ))
308        })? {
309            ManifestKindSniff::WorkspaceLike
310            | ManifestKindSniff::BrokenWorkspaceLike
311            | ManifestKindSniff::Mixed => {
312                return load_workspace_manifest_candidate(&candidate);
313            }
314            ManifestKindSniff::ConfigLike
315            | ManifestKindSniff::Unknown
316            | ManifestKindSniff::Unparsable => continue,
317        }
318    }
319
320    Err(workspace_manifest_discovery_error(
321        numi_config::DiscoveryError::NotFound {
322            start_dir: canonical_cwd,
323        },
324    ))
325}
326
327fn require_workspace_manifest(loaded: LoadedManifest) -> Result<LoadedManifest, CliError> {
328    match loaded.manifest {
329        Manifest::Workspace(_) => Ok(loaded),
330        Manifest::Config(_) => Err(CliError::new(format!(
331            "expected a workspace manifest at {}; pass --config <workspace>/numi.toml or remove --workspace",
332            loaded.path.display()
333        ))),
334    }
335}
336
337fn load_workspace_manifest_candidate(path: &Path) -> Result<LoadedManifest, CliError> {
338    let loaded = numi_config::load_manifest_from_path(path).map_err(|error| {
339        CliError::new(format!(
340            "failed to load workspace manifest {}: {error}",
341            path.display()
342        ))
343    })?;
344    require_workspace_manifest(loaded)
345}
346
347fn workspace_manifest_discovery_error(error: numi_config::DiscoveryError) -> CliError {
348    match error {
349        numi_config::DiscoveryError::ExplicitPathNotFound(path) => CliError::new(format!(
350            "workspace manifest not found: {}\n\npass --config <workspace>/numi.toml or remove --workspace",
351            path.display()
352        )),
353        numi_config::DiscoveryError::NotFound { start_dir } => CliError::new(format!(
354            "No workspace manifest found from {}\n\nRun this from a workspace member directory with an ancestor numi.toml, or pass --config <workspace>/numi.toml",
355            start_dir.display()
356        )),
357        numi_config::DiscoveryError::Ambiguous { root, matches } => {
358            let lines = matches
359                .iter()
360                .map(|path| format!("  - {}", path.display()))
361                .collect::<Vec<_>>()
362                .join("\n");
363            CliError::new(format!(
364                "Multiple workspace manifests found under {}:\n{}\n\npass --config <workspace>/numi.toml",
365                root.display(),
366                lines
367            ))
368        }
369        numi_config::DiscoveryError::Io(error) => CliError::new(error.to_string()),
370    }
371}
372
373fn current_dir() -> Result<PathBuf, CliError> {
374    std::env::current_dir().map_err(|error| CliError::new(format!("failed to read cwd: {error}")))
375}
376
377fn load_starter_config() -> Result<Cow<'static, str>, CliError> {
378    Ok(Cow::Borrowed(STARTER_CONFIG_FALLBACK))
379}
380
381fn manifest_dir(manifest_path: &Path) -> Result<&Path, CliError> {
382    manifest_path
383        .parent()
384        .filter(|path| !path.as_os_str().is_empty())
385        .ok_or_else(|| {
386            CliError::new(format!(
387                "manifest {} has no parent directory",
388                manifest_path.display()
389            ))
390        })
391}
392
393fn discover_config_path(explicit_path: Option<&Path>) -> Result<PathBuf, CliError> {
394    let cwd = current_dir()?;
395    numi_config::discover_config(&cwd, explicit_path)
396        .map_err(|error| CliError::new(error.to_string()))
397}
398
399fn selected_jobs(jobs: &[String]) -> Option<&[String]> {
400    (!jobs.is_empty()).then_some(jobs)
401}
402
403fn workspace_member_root(member: &WorkspaceMember) -> String {
404    Path::new(&member.config)
405        .parent()
406        .filter(|path| !path.as_os_str().is_empty())
407        .map(display_path)
408        .unwrap_or_else(|| String::from("."))
409}
410
411fn workspace_member_jobs(member: &WorkspaceMember) -> Option<&[String]> {
412    (!member.jobs.is_empty()).then_some(member.jobs.as_slice())
413}
414
415fn workspace_jobs<T>(args: &T, member: &WorkspaceMember) -> Option<Vec<String>>
416where
417    T: WorkspaceJobArgs,
418{
419    match (args.selected_jobs(), workspace_member_jobs(member)) {
420        (None, None) => None,
421        (Some(cli_jobs), None) => Some(cli_jobs.to_vec()),
422        (None, Some(member_jobs)) => Some(member_jobs.to_vec()),
423        (Some(cli_jobs), Some(member_jobs)) => {
424            let allowed_jobs = member_jobs
425                .iter()
426                .map(String::as_str)
427                .collect::<BTreeSet<_>>();
428            Some(
429                cli_jobs
430                    .iter()
431                    .filter(|job| allowed_jobs.contains(job.as_str()))
432                    .cloned()
433                    .collect(),
434            )
435        }
436    }
437}
438
439fn normalize_workspace_stale_path(path: &Path, workspace_dir: &Path) -> PathBuf {
440    path.strip_prefix(workspace_dir)
441        .map(Path::to_path_buf)
442        .unwrap_or_else(|_| path.to_path_buf())
443}
444
445fn print_warnings<T: std::fmt::Display>(warnings: &[T]) {
446    for warning in warnings {
447        eprintln!("{warning}");
448    }
449}
450
451fn render_config_diagnostics<I, T>(diagnostics: I) -> CliError
452where
453    I: IntoIterator<Item = T>,
454    T: std::fmt::Display,
455{
456    let message = diagnostics
457        .into_iter()
458        .map(|diagnostic| diagnostic.to_string())
459        .collect::<Vec<_>>()
460        .join("\n");
461    CliError::new(message)
462}
463
464fn display_path(path: impl AsRef<Path>) -> String {
465    path.as_ref().to_string_lossy().into_owned()
466}
467
468trait WorkspaceJobArgs {
469    fn selected_jobs(&self) -> Option<&[String]>;
470}
471
472impl WorkspaceJobArgs for GenerateArgs {
473    fn selected_jobs(&self) -> Option<&[String]> {
474        selected_jobs(&self.jobs)
475    }
476}
477
478impl WorkspaceJobArgs for CheckArgs {
479    fn selected_jobs(&self) -> Option<&[String]> {
480        selected_jobs(&self.jobs)
481    }
482}