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}