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