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