Skip to main content

artifactize_tools/
cli.rs

1//! Shared command-line definition and current-directory tool runner.
2
3use std::{collections::BTreeMap, io::Write, path::PathBuf};
4
5use crate::{
6    Builtin, Content, builtin,
7    scope::{Artifact, ArtifactId, ArtifactKind, Scope},
8};
9use clap::{Parser, Subcommand};
10use serde_json::json;
11use tokio_util::sync::CancellationToken;
12
13#[derive(Parser)]
14#[command(
15    disable_help_subcommand = true,
16    version,
17    about = "Portable scoped tools for artifactize",
18    long_about = "Portable scoped tools for artifactize. Filesystem targets must be relative to the current directory and stay below it. Parent components, absolute paths, and symlinks are refused. Use '.' for the current directory. Only open accepts HTTP(S) URLs. help resolves a literal program through the system's program search path."
19)]
20pub struct Cli {
21    #[command(subcommand)]
22    pub command: Tool,
23}
24
25#[derive(Debug, Subcommand)]
26pub enum Tool {
27    /// Print a UTF-8 file (at most 64 KiB).
28    Read { path: String },
29    /// List a directory, as JSON.
30    List {
31        #[arg(default_value = ".")]
32        path: String,
33    },
34    /// Find logical file paths, as JSON.
35    Glob {
36        pattern: String,
37        #[arg(default_value = ".")]
38        path: String,
39    },
40    /// Search UTF-8 files, as JSON.
41    Grep {
42        pattern: String,
43        #[arg(default_value = ".")]
44        path: String,
45        #[arg(long)]
46        glob: Option<String>,
47        #[arg(long)]
48        case_insensitive: bool,
49    },
50    /// Print a Markdown section, selected by its exact heading.
51    Section { path: String, heading: String },
52    /// Run PROGRAM SUBCOMMAND... --help (10 seconds, 64 KiB).
53    Help {
54        #[arg(required = true, num_args = 1.., trailing_var_arg = true)]
55        args: Vec<String>,
56    },
57    /// Open one scoped path or HTTP(S) URL in its default application.
58    Open { target: String },
59}
60
61/// Execute a parsed standalone tool with the current directory as its scope.
62/// The facade and standalone binary share output and exit codes, not just parsing.
63pub async fn run(tool: Tool) -> u8 {
64    match execute(tool).await {
65        Ok(()) => 0,
66        Err(error) => {
67            let _ = writeln!(std::io::stderr(), "{error}");
68            1
69        }
70    }
71}
72
73fn logical(path: String) -> String {
74    if path == "." { String::new() } else { path }
75}
76
77async fn execute(tool: Tool) -> Result<(), String> {
78    let root = crate::files::current_directory().map_err(|error| error.to_string())?;
79    let owner = ArtifactId::new("cwd").expect("static identifier");
80    let scope = Scope {
81        artifacts: BTreeMap::from([(
82            owner.clone(),
83            Artifact {
84                path: PathBuf::new(),
85                kind: ArtifactKind::Folder,
86                name: "cwd".into(),
87                children: BTreeMap::new(),
88            },
89        )]),
90    };
91    let cancellation = CancellationToken::new();
92    enum Invocation {
93        Fixed(Builtin, Vec<String>),
94        Dynamic(builtin::Input),
95    }
96    let invocation = match tool {
97        Tool::Read { path } => Invocation::Fixed(Builtin::Read, vec![logical(path)]),
98        Tool::List { path } => Invocation::Fixed(Builtin::List, vec![logical(path)]),
99        Tool::Section { path, heading } => {
100            Invocation::Fixed(Builtin::Section, vec![logical(path), heading])
101        }
102        Tool::Help { args } => Invocation::Fixed(Builtin::Help, args),
103        Tool::Glob { pattern, path } => {
104            Invocation::Dynamic(builtin::Input::glob(&pattern, logical(path))?)
105        }
106        Tool::Grep {
107            pattern,
108            path,
109            glob,
110            case_insensitive,
111        } => Invocation::Dynamic(builtin::Input::grep(
112            &pattern,
113            logical(path),
114            glob.as_deref(),
115            case_insensitive,
116        )?),
117        Tool::Open { target } => {
118            let target = if builtin::is_url(&target) {
119                target.into()
120            } else {
121                scope
122                    .resolve_input(&root, &owner, &logical(target))
123                    .map_err(|error| error.to_string())?
124                    .into_os_string()
125            };
126            return crate::opener::open(&target)
127                .await
128                .map_err(|error| error.to_string());
129        }
130    };
131    let result = match invocation {
132        Invocation::Fixed(tool, args) => {
133            builtin::call_fixed(
134                tool,
135                &args,
136                json!({}),
137                builtin::Context {
138                    root: &root,
139                    scope: &scope,
140                    owner: &owner,
141                    launcher: &crate::launch::Standalone,
142                },
143                &cancellation,
144            )
145            .await
146        }
147        Invocation::Dynamic(input) => builtin::call(input, &root, &scope, &owner, &cancellation),
148    };
149    let (mut stdout, mut stderr) = (std::io::stdout().lock(), std::io::stderr().lock());
150    for content in result.content() {
151        match content {
152            Content::Text { text } if result.is_error() => writeln!(stderr, "{text}"),
153            Content::Text { text } => write!(stdout, "{text}"),
154            Content::Json { data } => writeln!(
155                stdout,
156                "{}",
157                serde_json::to_string_pretty(&data).expect("builtin JSON")
158            ),
159            Content::Image { .. } => unreachable!("CLI tools produce no images"),
160        }
161        .map_err(|error| error.to_string())?;
162    }
163    if result.is_error() {
164        Err("Tool failed.".into())
165    } else {
166        Ok(())
167    }
168}