1use std::{collections::HashMap, path::Path};
2
3use anyhow::{Result, anyhow};
4use inquire::{Confirm, Select, Text};
5
6use crate::{
7 AppContext,
8 templates::generator::{to_camel_case, to_kebab_case, to_pascal_case, to_snake_case},
9};
10
11use super::{
12 cache::{get_cached_addon, is_addon_installed},
13 detect::detect_variant,
14 install::{install_addon, read_cached_manifest},
15 lock::{LockEntry, LockFile},
16 manifest::{AddonManifest, InputDef, InputType},
17 steps::{
18 Rollback, append::execute_append, copy::execute_copy, create::execute_create,
19 delete::execute_delete, inject::execute_inject, move_step::execute_move,
20 rename::execute_rename, replace::execute_replace,
21 },
22};
23use crate::addons::manifest::Step;
24
25pub async fn run_addon_command(
26 ctx: &AppContext,
27 addon_id: &str,
28 command_name: &str,
29 project_root: &Path,
30) -> Result<()> {
31 let manifest: AddonManifest = if get_cached_addon(&ctx.paths.addons, addon_id)?.is_some() {
33 read_cached_manifest(&ctx.paths.addons, addon_id)?
34 } else {
35 install_addon(ctx, addon_id).await?
36 };
37
38 let mut lock = LockFile::load(project_root)?;
40
41 for dep_id in &manifest.requires {
46 if !is_addon_installed(&ctx.paths.addons, dep_id)? {
47 return Err(anyhow!(
48 "Addon '{}' requires '{}' to be installed first. Run: oxide addon install {}",
49 addon_id,
50 dep_id,
51 dep_id
52 ));
53 }
54 }
55
56 let mut input_values: HashMap<String, String> = HashMap::new();
58 collect_inputs(&manifest.inputs, &mut input_values)?;
59
60 let mut tera_ctx = tera::Context::new();
62 insert_with_derived(&mut tera_ctx, &input_values);
63
64 let detected_id = detect_variant(&manifest.detect, project_root);
66
67 let variant = manifest
69 .variants
70 .iter()
71 .find(|v| v.when.as_deref() == detected_id.as_deref())
72 .or_else(|| manifest.variants.iter().find(|v| v.when.is_none()))
73 .ok_or_else(|| anyhow!("No matching variant found for addon '{}'", addon_id))?;
74
75 let command = variant
77 .commands
78 .iter()
79 .find(|c| c.name == command_name)
80 .ok_or_else(|| {
81 anyhow!(
82 "Command '{}' not found in addon '{}'",
83 command_name,
84 addon_id
85 )
86 })?;
87
88 if command.once && lock.is_command_executed(addon_id, command_name) {
90 println!(
91 "Command '{}' has already been executed, skipping.",
92 command_name
93 );
94 return Ok(());
95 }
96
97 for req_cmd in &command.requires_commands {
99 if !lock.is_command_executed(addon_id, req_cmd) {
100 return Err(anyhow!(
101 "Command '{}' requires '{}' to be run first. Run: oxide addon run {} {} {}",
102 command_name,
103 req_cmd,
104 addon_id,
105 req_cmd,
106 project_root.display()
107 ));
108 }
109 }
110
111 let mut cmd_input_values: HashMap<String, String> = HashMap::new();
113 collect_inputs(&command.inputs, &mut cmd_input_values)?;
114 insert_with_derived(&mut tera_ctx, &cmd_input_values);
115
116 let addon_dir = ctx.paths.addons.join(addon_id);
118 let mut completed_rollbacks: Vec<Rollback> = Vec::new();
119
120 for (idx, step) in command.steps.iter().enumerate() {
121 let result = match step {
122 Step::Copy(s) => execute_copy(s, &addon_dir, project_root),
123 Step::Create(s) => execute_create(s, project_root, &tera_ctx),
124 Step::Inject(s) => execute_inject(s, project_root, &tera_ctx),
125 Step::Replace(s) => execute_replace(s, project_root, &tera_ctx),
126 Step::Append(s) => execute_append(s, project_root, &tera_ctx),
127 Step::Delete(s) => execute_delete(s, project_root),
128 Step::Rename(s) => execute_rename(s, project_root, &tera_ctx),
129 Step::Move(s) => execute_move(s, project_root, &tera_ctx),
130 };
131
132 match result {
133 Ok(rollbacks) => completed_rollbacks.extend(rollbacks),
134 Err(err) => {
135 eprintln!("Step {} failed: {}", idx + 1, err);
136 let choice = Select::new(
137 "How would you like to proceed?",
138 vec!["Keep changes made so far", "Rollback all changes"],
139 )
140 .prompt()?;
141
142 if choice == "Rollback all changes" {
143 for rollback in completed_rollbacks.into_iter().rev() {
144 let _ = apply_rollback(rollback);
145 }
146 }
147
148 return Err(anyhow!("Addon command failed at step {}: {}", idx + 1, err));
149 }
150 }
151 }
152
153 lock.mark_command_executed(addon_id, command_name);
155 let variant_id = detected_id.unwrap_or_else(|| "universal".to_string());
156 lock.upsert_entry(LockEntry {
157 id: addon_id.to_string(),
158 version: manifest.version.clone(),
159 variant: variant_id,
160 commands_executed: lock
161 .addons
162 .iter()
163 .find(|e| e.id == addon_id)
164 .map(|e| e.commands_executed.clone())
165 .unwrap_or_default(),
166 });
167 lock.save(project_root)?;
168
169 println!("✓ Command '{}' completed successfully.", command_name);
170 Ok(())
171}
172
173fn collect_inputs(inputs: &[InputDef], map: &mut HashMap<String, String>) -> Result<()> {
175 for input in inputs {
176 let value = match input.input_type {
177 InputType::Text => {
178 let mut prompt = Text::new(&input.description);
179 if let Some(ref default) = input.default {
180 prompt = prompt.with_default(default);
181 }
182 prompt.prompt()?
183 }
184 InputType::Boolean => {
185 let default = input
186 .default
187 .as_deref()
188 .map(|d| d == "true")
189 .unwrap_or(false);
190 Confirm::new(&input.description)
191 .with_default(default)
192 .prompt()?
193 .to_string()
194 }
195 InputType::Select => Select::new(&input.description, input.options.clone())
196 .prompt()?
197 .to_string(),
198 };
199 map.insert(input.name.clone(), value);
200 }
201 Ok(())
202}
203
204fn insert_with_derived(ctx: &mut tera::Context, map: &HashMap<String, String>) {
207 for (k, v) in map {
208 ctx.insert(k.as_str(), v);
209 ctx.insert(&format!("{k}_pascal"), &to_pascal_case(v));
210 ctx.insert(&format!("{k}_camel"), &to_camel_case(v));
211 ctx.insert(&format!("{k}_kebab"), &to_kebab_case(v));
212 ctx.insert(&format!("{k}_snake"), &to_snake_case(v));
213 }
214}
215
216fn apply_rollback(rollback: Rollback) -> Result<()> {
217 match rollback {
218 Rollback::DeleteCreatedFile { path } => {
219 let _ = std::fs::remove_file(path);
220 }
221 Rollback::RestoreFile { path, original } => {
222 std::fs::write(path, original)?;
223 }
224 Rollback::RenameFile { from, to } => {
225 std::fs::rename(from, to)?;
226 }
227 }
228 Ok(())
229}