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  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
176/// Prompts for a list of inputs and inserts results into `map`.
177fn 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
207/// Inserts every key/value from `map` into `ctx`, plus derived case variants:
208/// `{key}_pascal`, `{key}_camel`, `{key}_kebab`, `{key}_snake`.
209fn 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}