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::{
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,
19    append::execute_append,
20    copy::execute_copy,
21    create::execute_create,
22    delete::execute_delete,
23    inject::execute_inject,
24    move_step::execute_move,
25    rename::execute_rename,
26    replace::execute_replace,
27  },
28};
29use crate::addons::manifest::Step;
30
31pub async fn run_addon_command(
32  ctx: &AppContext,
33  addon_id: &str,
34  command_name: &str,
35  project_root: &Path,
36) -> Result<()> {
37  // 1. Load manifest
38  let manifest: AddonManifest = if get_cached_addon(&ctx.paths.addons, addon_id)?.is_some() {
39    read_cached_manifest(&ctx.paths.addons, addon_id)?
40  } else {
41    install_addon(ctx, addon_id).await?
42  };
43
44  // 2. Load lock file
45  let mut lock = LockFile::load(project_root)?;
46
47  // 3. Check `once`
48  // (deferred until we find the command below, after variant detection)
49
50  // 4. Check addon deps (`requires`)
51  for dep_id in &manifest.requires {
52    if !is_addon_installed(&ctx.paths.addons, dep_id)? {
53      return Err(anyhow!(
54        "Addon '{}' requires '{}' to be installed first. Run: oxide addon install {}",
55        addon_id,
56        dep_id,
57        dep_id
58      ));
59    }
60  }
61
62  // 5. Collect manifest-level inputs
63  let mut input_values: HashMap<String, String> = HashMap::new();
64  collect_inputs(&manifest.inputs, &mut input_values)?;
65
66  // 6. Build Tera context from manifest inputs + derived case variants
67  let mut tera_ctx = tera::Context::new();
68  insert_with_derived(&mut tera_ctx, &input_values);
69
70  // 7. Detect variant
71  let detected_id = detect_variant(&manifest.detect, project_root);
72
73  // 8. Select variant
74  let variant = manifest
75    .variants
76    .iter()
77    .find(|v| v.when.as_deref() == detected_id.as_deref())
78    .or_else(|| manifest.variants.iter().find(|v| v.when.is_none()))
79    .ok_or_else(|| anyhow!("No matching variant found for addon '{}'", addon_id))?;
80
81  // 9. Find command
82  let command = variant
83    .commands
84    .iter()
85    .find(|c| c.name == command_name)
86    .ok_or_else(|| anyhow!("Command '{}' not found in addon '{}'", command_name, addon_id))?;
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!("Command '{}' has already been executed, skipping.", command_name);
91    return Ok(());
92  }
93
94  // 4b. Check `requires_commands`
95  for req_cmd in &command.requires_commands {
96    if !lock.is_command_executed(addon_id, req_cmd) {
97      return Err(anyhow!(
98        "Command '{}' requires '{}' to be run first. Run: oxide addon run {} {} {}",
99        command_name,
100        req_cmd,
101        addon_id,
102        req_cmd,
103        project_root.display()
104      ));
105    }
106  }
107
108  // 5b. Collect command-level inputs and add to context
109  let mut cmd_input_values: HashMap<String, String> = HashMap::new();
110  collect_inputs(&command.inputs, &mut cmd_input_values)?;
111  insert_with_derived(&mut tera_ctx, &cmd_input_values);
112
113  // 10. Execute steps
114  let addon_dir = ctx.paths.addons.join(addon_id);
115  let mut completed_rollbacks: Vec<Rollback> = Vec::new();
116
117  for (idx, step) in command.steps.iter().enumerate() {
118    let result = match step {
119      Step::Copy(s) => execute_copy(s, &addon_dir, project_root),
120      Step::Create(s) => execute_create(s, project_root, &tera_ctx),
121      Step::Inject(s) => execute_inject(s, project_root, &tera_ctx),
122      Step::Replace(s) => execute_replace(s, project_root, &tera_ctx),
123      Step::Append(s) => execute_append(s, project_root, &tera_ctx),
124      Step::Delete(s) => execute_delete(s, project_root),
125      Step::Rename(s) => execute_rename(s, project_root, &tera_ctx),
126      Step::Move(s) => execute_move(s, project_root, &tera_ctx),
127    };
128
129    match result {
130      Ok(rollbacks) => completed_rollbacks.extend(rollbacks),
131      Err(err) => {
132        eprintln!("Step {} failed: {}", idx + 1, err);
133        let choice = Select::new(
134          "How would you like to proceed?",
135          vec!["Keep changes made so far", "Rollback all changes"],
136        )
137        .prompt()?;
138
139        if choice == "Rollback all changes" {
140          for rollback in completed_rollbacks.into_iter().rev() {
141            let _ = apply_rollback(rollback);
142          }
143        }
144
145        return Err(anyhow!("Addon command failed at step {}: {}", idx + 1, err));
146      }
147    }
148  }
149
150  // 11. Update lock
151  lock.mark_command_executed(addon_id, command_name);
152  let variant_id = detected_id.unwrap_or_else(|| "universal".to_string());
153  lock.upsert_entry(LockEntry {
154    id: addon_id.to_string(),
155    version: manifest.version.clone(),
156    variant: variant_id,
157    commands_executed: lock
158      .addons
159      .iter()
160      .find(|e| e.id == addon_id)
161      .map(|e| e.commands_executed.clone())
162      .unwrap_or_default(),
163  });
164  lock.save(project_root)?;
165
166  println!("✓ Command '{}' completed successfully.", command_name);
167  Ok(())
168}
169
170/// Prompts for a list of inputs and inserts results into `map`.
171fn collect_inputs(inputs: &[InputDef], map: &mut HashMap<String, String>) -> Result<()> {
172  for input in inputs {
173    let value = match input.input_type {
174      InputType::Text => {
175        let mut prompt = Text::new(&input.description);
176        if let Some(ref default) = input.default {
177          prompt = prompt.with_default(default);
178        }
179        prompt.prompt()?
180      }
181      InputType::Boolean => {
182        let default = input.default.as_deref().map(|d| d == "true").unwrap_or(false);
183        Confirm::new(&input.description).with_default(default).prompt()?.to_string()
184      }
185      InputType::Select => {
186        Select::new(&input.description, input.options.clone()).prompt()?.to_string()
187      }
188    };
189    map.insert(input.name.clone(), value);
190  }
191  Ok(())
192}
193
194/// Inserts every key/value from `map` into `ctx`, plus derived case variants:
195/// `{key}_pascal`, `{key}_camel`, `{key}_kebab`, `{key}_snake`.
196fn insert_with_derived(ctx: &mut tera::Context, map: &HashMap<String, String>) {
197  for (k, v) in map {
198    ctx.insert(k.as_str(), v);
199    ctx.insert(&format!("{k}_pascal"), &to_pascal_case(v));
200    ctx.insert(&format!("{k}_camel"), &to_camel_case(v));
201    ctx.insert(&format!("{k}_kebab"), &to_kebab_case(v));
202    ctx.insert(&format!("{k}_snake"), &to_snake_case(v));
203  }
204}
205
206fn apply_rollback(rollback: Rollback) -> Result<()> {
207  match rollback {
208    Rollback::DeleteCreatedFile { path } => {
209      let _ = std::fs::remove_file(path);
210    }
211    Rollback::RestoreFile { path, original } => {
212      std::fs::write(path, original)?;
213    }
214    Rollback::RenameFile { from, to } => {
215      std::fs::rename(from, to)?;
216    }
217  }
218  Ok(())
219}