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#[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
34pub 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
56pub fn version_string() -> &'static str {
64 env!("CARGO_PKG_VERSION")
65}
66
67pub 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
92pub 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
111pub 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
124pub fn build_execution_plan(source: &str, cli: &CliInput) -> Result<ExecutionPlan> {
133 Ok(crate::compile::compile_for_cli_input(source, cli)?.plan)
134}
135
136pub 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
153pub 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}