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