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 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}