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                mounts: BTreeMap::new(),
89            },
90        )]),
91    };
92    let cancellation = CancellationToken::new();
93    let (tool, args, input) = match tool {
94        Tool::Read { path } => (Builtin::Read, Some(vec![logical(path)]), json!({})),
95        Tool::List { path } => (Builtin::List, Some(vec![logical(path)]), json!({})),
96        Tool::Section { path, heading } => (
97            Builtin::Section,
98            Some(vec![logical(path), heading]),
99            json!({}),
100        ),
101        Tool::Help { args } => (Builtin::Help, Some(args), json!({})),
102        Tool::Glob { pattern, path } => (
103            Builtin::Glob,
104            None,
105            json!({"pattern":pattern,"path":logical(path)}),
106        ),
107        Tool::Grep {
108            pattern,
109            path,
110            glob,
111            case_insensitive,
112        } => {
113            let mut input =
114                json!({"pattern":pattern,"path":logical(path),"caseInsensitive":case_insensitive});
115            if let Some(glob) = glob {
116                input["glob"] = json!(glob);
117            }
118            (Builtin::Grep, None, input)
119        }
120        Tool::Open { target } => {
121            let target = if builtin::is_url(&target) {
122                target.into()
123            } else {
124                scope
125                    .resolve_input(&root, &owner, &logical(target))
126                    .map_err(|error| error.to_string())?
127                    .into_os_string()
128            };
129            return crate::opener::open(&target)
130                .await
131                .map_err(|error| error.to_string());
132        }
133    };
134    let result = if let Some(args) = args {
135        let context = builtin::Context {
136            root: &root,
137            scope: &scope,
138            owner: &owner,
139            launcher: &crate::launch::Standalone,
140        };
141        builtin::call_fixed(tool, &args, input, context, &cancellation).await
142    } else {
143        let validator = crate::schema::compile(&builtin::input_schema(tool))?;
144        crate::schema::validate(&validator, &input)?;
145        builtin::call(
146            builtin::Input::parse(tool, input)?,
147            &root,
148            &scope,
149            &owner,
150            &cancellation,
151        )
152    };
153    let (mut stdout, mut stderr) = (std::io::stdout().lock(), std::io::stderr().lock());
154    for content in result.content() {
155        match content {
156            Content::Text { text } if result.is_error() => writeln!(stderr, "{text}"),
157            Content::Text { text } => write!(stdout, "{text}"),
158            Content::Json { data } => writeln!(
159                stdout,
160                "{}",
161                serde_json::to_string_pretty(&data).expect("builtin JSON")
162            ),
163            Content::Image { .. } => unreachable!("CLI tools produce no images"),
164        }
165        .map_err(|error| error.to_string())?;
166    }
167    if result.is_error() {
168        Err("Tool failed.".into())
169    } else {
170        Ok(())
171    }
172}