1use 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 Read { path: String },
29 List {
31 #[arg(default_value = ".")]
32 path: String,
33 },
34 Glob {
36 pattern: String,
37 #[arg(default_value = ".")]
38 path: String,
39 },
40 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 Section { path: String, heading: String },
52 Help {
54 #[arg(required = true, num_args = 1.., trailing_var_arg = true)]
55 args: Vec<String>,
56 },
57 Open { target: String },
59}
60
61pub 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}