Skip to main content

only/
command.rs

1use crate::args::{CliInput, parse_global_options, parse_with_onlyfile};
2use crate::compile::{
3    CliCompileResult, compile_for_cli_input_in_dir, ensure_no_error_diagnostics, resolve_target,
4};
5use crate::discover::discover_onlyfile;
6use crate::error::{OnlyError, Result};
7use crate::render::{
8    render_available_tasks, render_error_message, render_global_help, render_help_hint,
9    render_namespace_help,
10};
11use only_engine::{
12    ExecutionPlan, RuntimeOptions, render_command, run_plan_with_options, select_root_task_variant,
13};
14use only_semantic::{DocumentAst, GuardAst, ShellKind, TaskAst};
15use only_syntax::format_source;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19/// Loaded `Onlyfile` source plus parsed semantic document.
20///
21/// Args:
22/// None.
23///
24/// Returns:
25/// Host-side file metadata, raw contents, and parsed semantic document.
26#[derive(Debug, Clone)]
27pub struct LoadedOnlyfile {
28    pub path: PathBuf,
29    pub base_dir: PathBuf,
30    pub contents: String,
31    pub document: DocumentAst,
32}
33
34/// Runs the default CLI entry point with two-phase parsing.
35///
36/// Args:
37/// None.
38///
39/// Returns:
40/// Process exit code for the current invocation.
41pub fn run() -> ExitCode {
42    match run_inner() {
43        Ok(code) => code,
44        Err(OnlyError::NotFound(message)) => {
45            anstream::eprintln!("{}", render_error_message(&message));
46            anstream::eprintln!("{}", render_help_hint());
47            ExitCode::from(2)
48        }
49        Err(error) => {
50            anstream::eprintln!("{}", render_error_message(&error.to_string()));
51            ExitCode::from(2)
52        }
53    }
54}
55
56/// Returns the published CLI version string.
57///
58/// Args:
59/// None.
60///
61/// Returns:
62/// Static package version text.
63pub fn version_string() -> &'static str {
64    env!("CARGO_PKG_VERSION")
65}
66
67/// Runs the application with pre-parsed CLI input.
68///
69/// Args:
70/// cli: Normalized command-line input.
71///
72/// Returns:
73/// Process exit code for the requested action.
74pub fn run_with(cli: CliInput) -> Result<ExitCode> {
75    let discovered = discover_onlyfile(cli.onlyfile_path.as_deref())?;
76
77    if cli.print_discovered_path {
78        anstream::println!("{}", discovered.path.display());
79        return Ok(ExitCode::SUCCESS);
80    }
81
82    ensure_dry_run_has_target(&cli)?;
83
84    let compiled = compile_for_cli_input_in_dir(&discovered.contents, &cli, discovered.base_dir)?;
85    if cli.dry_run {
86        anstream::println!("{}", render_dry_run_for_cli(&compiled, &cli)?);
87        return Ok(ExitCode::SUCCESS);
88    }
89    run_compiled_plan(&compiled.plan, &cli)
90}
91
92/// Loads and parses the requested Onlyfile.
93///
94/// Args:
95/// path: Optional explicit file path.
96///
97/// Returns:
98/// Discovered Onlyfile metadata and parsed document.
99pub fn load_onlyfile(path: Option<&Path>) -> Result<LoadedOnlyfile> {
100    let discovered = discover_onlyfile(path)?;
101    let document = parse_onlyfile(&discovered.contents)?;
102
103    Ok(LoadedOnlyfile {
104        path: discovered.path,
105        base_dir: discovered.base_dir,
106        contents: discovered.contents,
107        document,
108    })
109}
110
111/// Parses Onlyfile source text into the current semantic document.
112///
113/// Args:
114/// content: Raw file contents.
115///
116/// Returns:
117/// Parsed semantic document.
118pub fn parse_onlyfile(content: &str) -> Result<DocumentAst> {
119    let compiled = only_semantic::compile_document_for_runner(content, env!("CARGO_PKG_VERSION"));
120    ensure_no_error_diagnostics(&compiled.diagnostics)?;
121    Ok(compiled.document)
122}
123
124/// Builds an execution plan for the requested CLI invocation from raw source text.
125///
126/// Args:
127/// source: Raw Onlyfile source text.
128/// cli: Normalized command-line input.
129///
130/// Returns:
131/// Resolved execution plan.
132pub fn build_execution_plan(source: &str, cli: &CliInput) -> Result<ExecutionPlan> {
133    Ok(crate::compile::compile_for_cli_input(source, cli)?.plan)
134}
135
136/// Builds an execution plan for the requested CLI invocation from raw source text and working directory.
137///
138/// Args:
139/// source: Raw Onlyfile source text.
140/// cli: Normalized command-line input.
141/// working_dir: Directory used during runtime execution.
142///
143/// Returns:
144/// Resolved execution plan.
145pub fn build_execution_plan_in_dir(
146    source: &str,
147    cli: &CliInput,
148    working_dir: PathBuf,
149) -> Result<ExecutionPlan> {
150    Ok(compile_for_cli_input_in_dir(source, cli, working_dir)?.plan)
151}
152
153/// Runs the resolved execution plan.
154///
155/// Args:
156/// plan: Resolved execution plan.
157///
158/// Returns:
159/// Process exit code from the executed plan.
160pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode> {
161    only_engine::run_plan(plan).map_err(|error| OnlyError::runtime(error.to_string()))
162}
163
164fn ensure_dry_run_has_target(cli: &CliInput) -> Result<()> {
165    if cli.dry_run_full && !cli.dry_run {
166        return Err(OnlyError::parse(
167            "--full only works with --dry-run\nhelp: use `only --dry-run --full <task>`",
168        ));
169    }
170
171    if cli.dry_run && cli.task_path.is_empty() {
172        return Err(OnlyError::parse(
173            "--dry-run needs a task\nhelp: use `only --dry-run <task>`",
174        ));
175    }
176
177    Ok(())
178}
179
180fn render_dry_run_for_cli(compiled: &CliCompileResult, cli: &CliInput) -> Result<String> {
181    let (target, _) = resolve_target(&compiled.compiled, cli)?;
182    let variant = select_root_task_variant(&compiled.compiled.document, &target)
183        .map_err(|error| OnlyError::runtime(error.to_string()))?;
184    render_dry_run(&compiled.plan, variant, cli.dry_run_full)
185}
186
187fn run_compiled_plan(plan: &ExecutionPlan, cli: &CliInput) -> Result<ExitCode> {
188    run_plan_with_options(
189        plan,
190        RuntimeOptions {
191            quiet: cli.quiet,
192            ..RuntimeOptions::default()
193        },
194    )
195    .map_err(|error| OnlyError::runtime(error.to_string()))
196}
197
198fn render_dry_run(plan: &ExecutionPlan, variant: &TaskAst, full: bool) -> Result<String> {
199    let mut output = String::new();
200    let header = format!("Dry run: {}", render_task_variant(variant));
201    push_line(&mut output, &header);
202
203    let stages = plan_stages(plan);
204    let mut index = 0usize;
205    while index < stages.len() {
206        let (stage, stage_nodes) = &stages[index];
207        let stage_last = index + 1 == stages.len();
208        let stage_label = render_stage_label(*stage, stage_nodes.len());
209        push_tree_line(&mut output, "", stage_last, &stage_label);
210        let stage_prefix = if stage_last { "   " } else { "│  " };
211
212        for (node_index, node) in stage_nodes.iter().enumerate() {
213            let node_last = node_index + 1 == stage_nodes.len();
214            let has_block = node.steps.iter().any(only_engine::ExecutionStep::is_block);
215            let node_label = if full || has_block {
216                node.name.to_string()
217            } else {
218                render_node_summary(&node.name, node.steps.len())
219            };
220            push_tree_line(&mut output, stage_prefix, node_last, &node_label);
221            if !full && !has_block {
222                continue;
223            }
224
225            let command_prefix = if node_last {
226                format!("{stage_prefix}   ")
227            } else {
228                format!("{stage_prefix}│  ")
229            };
230
231            let shell = node
232                .shell
233                .as_ref()
234                .map(|shell| shell.kind.as_str())
235                .or_else(|| plan.shell.as_ref().map(ShellKind::as_str))
236                .unwrap_or(ShellKind::Deno.as_str());
237            for (step_index, step) in node.steps.iter().enumerate() {
238                let rendered = render_command(step.source(), &node.params)
239                    .map_err(|error| OnlyError::runtime(error.to_string()))?;
240                let step_last = step_index + 1 == node.steps.len();
241                match step {
242                    only_engine::ExecutionStep::Command { .. } => {
243                        push_tree_line(&mut output, &command_prefix, step_last, &rendered);
244                    }
245                    only_engine::ExecutionStep::CommandBlock { line_count, .. } if !full => {
246                        push_tree_line(
247                            &mut output,
248                            &command_prefix,
249                            step_last,
250                            &format!("block ({shell}, {line_count} lines)"),
251                        );
252                    }
253                    only_engine::ExecutionStep::CommandBlock { .. } => {
254                        push_tree_line(
255                            &mut output,
256                            &command_prefix,
257                            step_last,
258                            &format!("block ({shell})"),
259                        );
260                        let line_prefix = if step_last {
261                            format!("{command_prefix}   ")
262                        } else {
263                            format!("{command_prefix}│  ")
264                        };
265                        let lines = rendered.lines().collect::<Vec<_>>();
266                        for (line_index, line) in lines.iter().enumerate() {
267                            push_tree_line(
268                                &mut output,
269                                &line_prefix,
270                                line_index + 1 == lines.len(),
271                                line,
272                            );
273                        }
274                    }
275                }
276            }
277        }
278
279        index += 1;
280    }
281
282    Ok(output.trim_end().to_string())
283}
284
285fn plan_stages(plan: &ExecutionPlan) -> Vec<(usize, Vec<&only_engine::ExecutionNode>)> {
286    let mut stages: Vec<(usize, Vec<&only_engine::ExecutionNode>)> = Vec::new();
287
288    for node in &plan.nodes {
289        let stage_index = node.stage;
290        if let Some((_, nodes)) = stages.iter_mut().find(|(stage, _)| *stage == stage_index) {
291            nodes.push(node);
292        } else {
293            stages.push((stage_index, vec![node]));
294        }
295    }
296    stages.sort_unstable_by_key(|(stage, _)| *stage);
297    stages
298}
299
300fn render_node_summary(name: &str, command_count: usize) -> String {
301    let noun = if command_count == 1 {
302        "command"
303    } else {
304        "commands"
305    };
306    format!("{name} ({command_count} {noun})")
307}
308
309fn render_stage_label(stage: usize, node_count: usize) -> String {
310    if node_count > 1 {
311        format!("stage {} (parallel)", stage + 1)
312    } else {
313        format!("stage {}", stage + 1)
314    }
315}
316
317fn push_tree_line(output: &mut String, prefix: &str, is_last: bool, text: &str) {
318    let mut line = String::new();
319    line.push_str(prefix);
320    line.push_str(if is_last { "└─ " } else { "├─ " });
321    line.push_str(text);
322    push_line(output, &line);
323}
324
325fn push_line(output: &mut String, line: &str) {
326    output.push_str(line);
327    output.push('\n');
328}
329
330fn render_task_variant(task: &TaskAst) -> String {
331    let mut variant = match &task.namespace {
332        Some(namespace) => format!("{namespace}.{}", task.signature()),
333        None => task.signature().to_string(),
334    };
335
336    for guard in &task.guards {
337        variant.push_str(" ? ");
338        variant.push_str(&render_guard(guard));
339    }
340
341    variant
342}
343
344fn render_guard(guard: &GuardAst) -> String {
345    format!("@{}(\"{}\")", guard.kind, guard.argument)
346}
347
348fn run_inner() -> Result<ExitCode> {
349    let partial = parse_global_options()?;
350
351    if partial.top_level_help_requested {
352        anstream::print!("{}", render_global_help().ansi());
353        return Ok(ExitCode::SUCCESS);
354    }
355
356    if partial.top_level_version_requested {
357        anstream::println!("{}", env!("CARGO_PKG_VERSION"));
358        return Ok(ExitCode::SUCCESS);
359    }
360
361    if partial.top_level_upgrade_requested {
362        return crate::upgrade::run_upgrade();
363    }
364
365    let discovered = discover_onlyfile(partial.onlyfile_path.as_deref())?;
366
367    if partial.print_discovered_path {
368        anstream::println!("{}", discovered.path.display());
369        return Ok(ExitCode::SUCCESS);
370    }
371
372    if partial.format_requested || partial.format_check {
373        if partial.format_check && !partial.format_requested {
374            return Err(OnlyError::parse("--check only works with --fmt"));
375        }
376        let _ = parse_onlyfile(&discovered.contents)?;
377        let formatted = format_source(&discovered.contents).map_err(OnlyError::parse)?;
378        if partial.format_check {
379            if formatted != discovered.contents {
380                let line = first_changed_line(&discovered.contents, &formatted);
381                anstream::println!(
382                    "{} needs formatting (first change: line {line})",
383                    discovered.path.display()
384                );
385                return Ok(ExitCode::from(1));
386            }
387            return Ok(ExitCode::SUCCESS);
388        }
389        write_formatted_file(&discovered.path, &formatted)?;
390        return Ok(ExitCode::SUCCESS);
391    }
392
393    let document = parse_onlyfile(&discovered.contents)?;
394    let discovered = LoadedOnlyfile {
395        path: discovered.path,
396        base_dir: discovered.base_dir,
397        contents: discovered.contents,
398        document,
399    };
400
401    let cli = parse_with_onlyfile(&discovered.document)?;
402
403    ensure_dry_run_has_target(&cli)?;
404
405    if cli.task_path.is_empty() {
406        anstream::print!("{}", render_available_tasks(&discovered.document));
407        return Ok(ExitCode::SUCCESS);
408    }
409
410    if let [namespace_name] = cli.task_path.as_slice()
411        && let Some(namespace) = discovered
412            .document
413            .namespaces
414            .iter()
415            .find(|namespace| namespace.name == *namespace_name)
416    {
417        anstream::println!(
418            "{}",
419            render_namespace_help(&discovered.document, namespace).ansi()
420        );
421        return Ok(ExitCode::SUCCESS);
422    }
423
424    let compiled = compile_for_cli_input_in_dir(&discovered.contents, &cli, discovered.base_dir)?;
425    if cli.dry_run {
426        anstream::println!("{}", render_dry_run_for_cli(&compiled, &cli)?);
427        return Ok(ExitCode::SUCCESS);
428    }
429    run_compiled_plan(&compiled.plan, &cli)
430}
431
432fn first_changed_line(original: &str, formatted: &str) -> usize {
433    original
434        .lines()
435        .zip(formatted.lines())
436        .position(|(left, right)| left != right)
437        .map_or_else(
438            || usize::min(original.lines().count(), formatted.lines().count()) + 1,
439            |index| index + 1,
440        )
441}
442
443fn write_formatted_file(path: &Path, contents: &str) -> Result<()> {
444    let parent = path.parent().unwrap_or_else(|| Path::new("."));
445    let temporary = parent.join(format!(".only-format-{}", std::process::id()));
446    std::fs::write(&temporary, contents).map_err(|error| OnlyError::runtime(error.to_string()))?;
447    std::fs::rename(&temporary, path).map_err(|error| {
448        let _ = std::fs::remove_file(&temporary);
449        OnlyError::runtime(error.to_string())
450    })
451}