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 let variant_id = detected_id.unwrap_or_else(|| "universal".to_string());
155 let mut commands_executed = lock
156 .addons
157 .iter()
158 .find(|e| e.id == addon_id)
159 .map(|e| e.commands_executed.clone())
160 .unwrap_or_default();
161 if !commands_executed.iter().any(|c| c == command_name) {
162 commands_executed.push(command_name.to_string());
163 }
164 lock.upsert_entry(LockEntry {
165 id: addon_id.to_string(),
166 version: manifest.version.clone(),
167 variant: variant_id,
168 commands_executed,
169 });
170 lock.save(project_root)?;
171
172 println!("✓ Command '{}' completed successfully.", command_name);
173 Ok(())
174}
175
176fn collect_inputs(inputs: &[InputDef], map: &mut HashMap<String, String>) -> Result<()> {
178 for input in inputs {
179 let value = match input.input_type {
180 InputType::Text => {
181 let mut prompt = Text::new(&input.description);
182 if let Some(ref default) = input.default {
183 prompt = prompt.with_default(default);
184 }
185 prompt.prompt()?
186 }
187 InputType::Boolean => {
188 let default = input
189 .default
190 .as_deref()
191 .map(|d| d == "true")
192 .unwrap_or(false);
193 Confirm::new(&input.description)
194 .with_default(default)
195 .prompt()?
196 .to_string()
197 }
198 InputType::Select => Select::new(&input.description, input.options.clone())
199 .prompt()?
200 .to_string(),
201 };
202 map.insert(input.name.clone(), value);
203 }
204 Ok(())
205}
206
207fn insert_with_derived(ctx: &mut tera::Context, map: &HashMap<String, String>) {
210 for (k, v) in map {
211 ctx.insert(k.as_str(), v);
212 ctx.insert(&format!("{k}_pascal"), &to_pascal_case(v));
213 ctx.insert(&format!("{k}_camel"), &to_camel_case(v));
214 ctx.insert(&format!("{k}_kebab"), &to_kebab_case(v));
215 ctx.insert(&format!("{k}_snake"), &to_snake_case(v));
216 }
217}
218
219fn apply_rollback(rollback: Rollback) -> Result<()> {
220 match rollback {
221 Rollback::DeleteCreatedFile { path } => {
222 let _ = std::fs::remove_file(path);
223 }
224 Rollback::RestoreFile { path, original } => {
225 std::fs::write(path, original)?;
226 }
227 Rollback::RenameFile { from, to } => {
228 std::fs::rename(from, to)?;
229 }
230 }
231 Ok(())
232}