1use std::{collections::HashMap, path::Path};
2
3use anyhow::{Result, anyhow};
4use inquire::{Confirm, Select, Text};
5use reqwest::StatusCode;
6
7use crate::{
8 AppContext,
9 templates::generator::{to_camel_case, to_kebab_case, to_pascal_case, to_snake_case},
10};
11
12use super::{
13 cache::{get_cached_addon, is_addon_installed},
14 detect::detect_variant,
15 install::{AddonInstallResult, install_addon, read_cached_manifest},
16 lock::{LockEntry, LockFile},
17 manifest::{AddonManifest, InputDef, InputType},
18 steps::{
19 Rollback, append::execute_append, copy::execute_copy, create::execute_create,
20 delete::execute_delete, inject::execute_inject, move_step::execute_move,
21 rename::execute_rename, replace::execute_replace,
22 },
23};
24use crate::addons::manifest::Step;
25
26pub async fn run_addon_command(
27 ctx: &AppContext,
28 addon_id: &str,
29 command_name: &str,
30 project_root: &Path,
31) -> Result<()> {
32 let addon_is_cached = get_cached_addon(&ctx.paths.addons, addon_id)?.is_some();
34 let install_result = match install_addon(ctx, addon_id).await {
35 Ok(install_result) => install_result,
36 Err(err) if addon_is_cached && should_fallback_to_cached_manifest(&err) => {
37 eprintln!(
38 "Note: Could not check for addon updates ({}). Using cached version.",
39 err
40 );
41 AddonInstallResult::UpToDate(read_cached_manifest(&ctx.paths.addons, addon_id)?)
42 }
43 Err(err) => return Err(err),
44 };
45 if let Some(message) = install_result.update_message(addon_id) {
46 println!("{message}");
47 }
48 let manifest: AddonManifest = install_result.into_manifest();
49
50 let mut lock = LockFile::load(project_root)?;
52
53 for dep_id in &manifest.requires {
58 if !is_addon_installed(&ctx.paths.addons, dep_id)? {
59 return Err(anyhow!(
60 "Addon '{}' requires '{}' to be installed first. Run: oxide addon install {}",
61 addon_id,
62 dep_id,
63 dep_id
64 ));
65 }
66 }
67
68 let mut input_values: HashMap<String, String> = HashMap::new();
70 collect_inputs(&manifest.inputs, &mut input_values)?;
71
72 let mut tera_ctx = tera::Context::new();
74 insert_with_derived(&mut tera_ctx, &input_values);
75
76 let detected_id = detect_variant(&manifest.detect, project_root);
78
79 let variant = manifest
81 .variants
82 .iter()
83 .find(|v| v.when.as_deref() == detected_id.as_deref())
84 .or_else(|| manifest.variants.iter().find(|v| v.when.is_none()))
85 .ok_or_else(|| anyhow!("No matching variant found for addon '{}'", addon_id))?;
86
87 let command = variant
89 .commands
90 .iter()
91 .find(|c| c.name == command_name)
92 .ok_or_else(|| {
93 anyhow!(
94 "Command '{}' not found in addon '{}'",
95 command_name,
96 addon_id
97 )
98 })?;
99
100 if command.once && lock.is_command_executed(addon_id, command_name) {
102 if let Some(prompt_message) = rerun_prompt_message(
103 command_name,
104 lock.addon_version(addon_id),
105 &manifest.version,
106 ) {
107 let rerun = Confirm::new(&prompt_message).with_default(false).prompt()?;
108 if !rerun {
109 println!("Skipping command '{}'.", command_name);
110 return Ok(());
111 }
112 } else {
113 println!(
114 "Command '{}' has already been executed, skipping.",
115 command_name
116 );
117 return Ok(());
118 }
119 }
120
121 for req_cmd in &command.requires_commands {
123 if !lock.is_command_executed(addon_id, req_cmd) {
124 return Err(anyhow!(
125 "Command '{}' requires '{}' to be run first. Run: oxide addon run {} {} {}",
126 command_name,
127 req_cmd,
128 addon_id,
129 req_cmd,
130 project_root.display()
131 ));
132 }
133 }
134
135 let mut cmd_input_values: HashMap<String, String> = HashMap::new();
137 collect_inputs(&command.inputs, &mut cmd_input_values)?;
138 insert_with_derived(&mut tera_ctx, &cmd_input_values);
139
140 let addon_dir = ctx.paths.addons.join(addon_id);
142 let mut completed_rollbacks: Vec<Rollback> = Vec::new();
143
144 for (idx, step) in command.steps.iter().enumerate() {
145 let result = match step {
146 Step::Copy(s) => execute_copy(s, &addon_dir, project_root),
147 Step::Create(s) => execute_create(s, project_root, &tera_ctx),
148 Step::Inject(s) => execute_inject(s, project_root, &tera_ctx),
149 Step::Replace(s) => execute_replace(s, project_root, &tera_ctx),
150 Step::Append(s) => execute_append(s, project_root, &tera_ctx),
151 Step::Delete(s) => execute_delete(s, project_root),
152 Step::Rename(s) => execute_rename(s, project_root, &tera_ctx),
153 Step::Move(s) => execute_move(s, project_root, &tera_ctx),
154 };
155
156 match result {
157 Ok(rollbacks) => completed_rollbacks.extend(rollbacks),
158 Err(err) => {
159 eprintln!("Step {} failed: {}", idx + 1, err);
160 let choice = Select::new(
161 "How would you like to proceed?",
162 vec!["Keep changes made so far", "Rollback all changes"],
163 )
164 .prompt()?;
165
166 if choice == "Rollback all changes" {
167 for rollback in completed_rollbacks.into_iter().rev() {
168 let _ = apply_rollback(rollback);
169 }
170 }
171
172 return Err(anyhow!("Addon command failed at step {}: {}", idx + 1, err));
173 }
174 }
175 }
176
177 let variant_id = detected_id.unwrap_or_else(|| "universal".to_string());
179 let mut commands_executed = lock
180 .addons
181 .iter()
182 .find(|e| e.id == addon_id)
183 .map(|e| e.commands_executed.clone())
184 .unwrap_or_default();
185 if !commands_executed.iter().any(|c| c == command_name) {
186 commands_executed.push(command_name.to_string());
187 }
188 lock.upsert_entry(LockEntry {
189 id: addon_id.to_string(),
190 version: manifest.version.clone(),
191 variant: variant_id,
192 commands_executed,
193 });
194 lock.save(project_root)?;
195
196 println!("✓ Command '{}' completed successfully.", command_name);
197 Ok(())
198}
199
200fn should_fallback_to_cached_manifest(error: &anyhow::Error) -> bool {
201 if error.to_string() == "You are not logged in yet." {
202 return true;
203 }
204
205 error.chain().any(|source| {
206 source
207 .downcast_ref::<reqwest::Error>()
208 .is_some_and(|reqwest_error| {
209 reqwest_error.is_connect()
210 || reqwest_error.is_timeout()
211 || reqwest_error.status().is_some_and(|status| {
212 matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
213 })
214 })
215 })
216}
217
218#[doc(hidden)]
219pub fn should_fallback_to_cached_manifest_for_tests(error: &anyhow::Error) -> bool {
220 should_fallback_to_cached_manifest(error)
221}
222
223fn rerun_prompt_message(
224 command_name: &str,
225 locked_version: Option<&str>,
226 current_version: &str,
227) -> Option<String> {
228 let locked_version = locked_version.filter(|version| !version.is_empty())?;
229 if locked_version == current_version {
230 return None;
231 }
232
233 Some(format!(
234 "Command '{}' was last run with v{} of this add-on. A new version (v{}) is available. Re-run it now?",
235 command_name, locked_version, current_version
236 ))
237}
238
239#[doc(hidden)]
240pub fn rerun_prompt_message_for_tests(
241 command_name: &str,
242 locked_version: Option<&str>,
243 current_version: &str,
244) -> Option<String> {
245 rerun_prompt_message(command_name, locked_version, current_version)
246}
247
248fn collect_inputs(inputs: &[InputDef], map: &mut HashMap<String, String>) -> Result<()> {
250 for input in inputs {
251 let value = match input.input_type {
252 InputType::Text => {
253 let mut prompt = Text::new(&input.description);
254 if let Some(ref default) = input.default {
255 prompt = prompt.with_default(default);
256 }
257 prompt.prompt()?
258 }
259 InputType::Boolean => {
260 let default = input
261 .default
262 .as_deref()
263 .map(|d| d == "true")
264 .unwrap_or(false);
265 Confirm::new(&input.description)
266 .with_default(default)
267 .prompt()?
268 .to_string()
269 }
270 InputType::Select => Select::new(&input.description, input.options.clone())
271 .prompt()?
272 .to_string(),
273 };
274 map.insert(input.name.clone(), value);
275 }
276 Ok(())
277}
278
279fn insert_with_derived(ctx: &mut tera::Context, map: &HashMap<String, String>) {
282 for (k, v) in map {
283 ctx.insert(k.as_str(), v);
284 ctx.insert(format!("{k}_pascal"), &to_pascal_case(v));
285 ctx.insert(format!("{k}_camel"), &to_camel_case(v));
286 ctx.insert(format!("{k}_kebab"), &to_kebab_case(v));
287 ctx.insert(format!("{k}_snake"), &to_snake_case(v));
288 }
289}
290
291fn apply_rollback(rollback: Rollback) -> Result<()> {
292 match rollback {
293 Rollback::DeleteCreatedFile { path } => {
294 let _ = std::fs::remove_file(path);
295 }
296 Rollback::RestoreFile { path, original } => {
297 std::fs::write(path, original)?;
298 }
299 Rollback::RenameFile { from, to } => {
300 std::fs::rename(from, to)?;
301 }
302 }
303 Ok(())
304}