Skip to main content

oxide_cli/addons/
runner.rs

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  // 1. Load manifest
35  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  // 2. Load lock file
42  let mut lock = LockFile::load(project_root)?;
43
44  // 3. Check `once`
45  // (deferred until we find the command below, after variant detection)
46
47  // 4. Check addon deps (`requires`)
48  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  // 5. Collect inputs
60  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  // 6. Build Tera context
87  let mut tera_ctx = tera::Context::new();
88  for (k, v) in &input_values {
89    tera_ctx.insert(k, v);
90  }
91
92  // 7. Detect variant
93  let detected_id = detect_variant(&manifest.detect, project_root);
94
95  // 8. Select variant
96  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  // 9. Find command
104  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  // 3. Check `once` (now that we have the command)
111  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  // 4b. Check `requires_commands`
117  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  // 10. Execute steps
131  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  // 11. Update lock
168  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}