Skip to main content

oxide_cli/addons/
runner.rs

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  // 1. Load manifest
32  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  // 2. Load lock file
39  let mut lock = LockFile::load(project_root)?;
40
41  // 3. Check `once`
42  // (deferred until we find the command below, after variant detection)
43
44  // 4. Check addon deps (`requires`)
45  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  // 5. Collect manifest-level inputs
57  let mut input_values: HashMap<String, String> = HashMap::new();
58  collect_inputs(&manifest.inputs, &mut input_values)?;
59
60  // 6. Build Tera context from manifest inputs + derived case variants
61  let mut tera_ctx = tera::Context::new();
62  insert_with_derived(&mut tera_ctx, &input_values);
63
64  // 7. Detect variant
65  let detected_id = detect_variant(&manifest.detect, project_root);
66
67  // 8. Select variant
68  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  // 9. Find command
76  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  // 3. Check `once` (now that we have the command)
89  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  // 4b. Check `requires_commands`
98  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  // 5b. Collect command-level inputs and add to context
112  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  // 10. Execute steps
117  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  // 11. Update lock
154  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
173/// Prompts for a list of inputs and inserts results into `map`.
174fn 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
204/// Inserts every key/value from `map` into `ctx`, plus derived case variants:
205/// `{key}_pascal`, `{key}_camel`, `{key}_kebab`, `{key}_snake`.
206fn 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}