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};
5use reqwest::StatusCode;
6
7use crate::{
8  AppContext,
9  templates::generator::{to_camel_case, to_kebab_case, to_pascal_case, to_snake_case},
10};
11
12use super::{
13  cache::{get_cached_addon, is_addon_installed},
14  detect::detect_variant,
15  install::{AddonInstallResult, install_addon, read_cached_manifest},
16  lock::{LockEntry, LockFile},
17  manifest::{AddonManifest, InputDef, InputType},
18  steps::{
19    Rollback, append::execute_append, copy::execute_copy, create::execute_create,
20    delete::execute_delete, inject::execute_inject, move_step::execute_move,
21    rename::execute_rename, replace::execute_replace,
22  },
23};
24use crate::addons::manifest::Step;
25
26pub async fn run_addon_command(
27  ctx: &AppContext,
28  addon_id: &str,
29  command_name: &str,
30  project_root: &Path,
31) -> Result<()> {
32  // 1. Load manifest
33  let addon_is_cached = get_cached_addon(&ctx.paths.addons, addon_id)?.is_some();
34  let install_result = match install_addon(ctx, addon_id).await {
35    Ok(install_result) => install_result,
36    Err(err) if addon_is_cached && should_fallback_to_cached_manifest(&err) => {
37      eprintln!(
38        "Note: Could not check for addon updates ({}). Using cached version.",
39        err
40      );
41      AddonInstallResult::UpToDate(read_cached_manifest(&ctx.paths.addons, addon_id)?)
42    }
43    Err(err) => return Err(err),
44  };
45  if let Some(message) = install_result.update_message(addon_id) {
46    println!("{message}");
47  }
48  let manifest: AddonManifest = install_result.into_manifest();
49
50  // 2. Load lock file
51  let mut lock = LockFile::load(project_root)?;
52
53  // 3. Check `once`
54  // (deferred until we find the command below, after variant detection)
55
56  // 4. Check addon deps (`requires`)
57  for dep_id in &manifest.requires {
58    if !is_addon_installed(&ctx.paths.addons, dep_id)? {
59      return Err(anyhow!(
60        "Addon '{}' requires '{}' to be installed first. Run: oxide addon install {}",
61        addon_id,
62        dep_id,
63        dep_id
64      ));
65    }
66  }
67
68  // 5. Collect manifest-level inputs
69  let mut input_values: HashMap<String, String> = HashMap::new();
70  collect_inputs(&manifest.inputs, &mut input_values)?;
71
72  // 6. Build Tera context from manifest inputs + derived case variants
73  let mut tera_ctx = tera::Context::new();
74  insert_with_derived(&mut tera_ctx, &input_values);
75
76  // 7. Detect variant
77  let detected_id = detect_variant(&manifest.detect, project_root);
78
79  // 8. Select variant
80  let variant = manifest
81    .variants
82    .iter()
83    .find(|v| v.when.as_deref() == detected_id.as_deref())
84    .or_else(|| manifest.variants.iter().find(|v| v.when.is_none()))
85    .ok_or_else(|| anyhow!("No matching variant found for addon '{}'", addon_id))?;
86
87  // 9. Find command
88  let command = variant
89    .commands
90    .iter()
91    .find(|c| c.name == command_name)
92    .ok_or_else(|| {
93      anyhow!(
94        "Command '{}' not found in addon '{}'",
95        command_name,
96        addon_id
97      )
98    })?;
99
100  // 3. Check `once` (now that we have the command)
101  if command.once && lock.is_command_executed(addon_id, command_name) {
102    if let Some(prompt_message) = rerun_prompt_message(
103      command_name,
104      lock.addon_version(addon_id),
105      &manifest.version,
106    ) {
107      let rerun = Confirm::new(&prompt_message).with_default(false).prompt()?;
108      if !rerun {
109        println!("Skipping command '{}'.", command_name);
110        return Ok(());
111      }
112    } else {
113      println!(
114        "Command '{}' has already been executed, skipping.",
115        command_name
116      );
117      return Ok(());
118    }
119  }
120
121  // 4b. Check `requires_commands`
122  for req_cmd in &command.requires_commands {
123    if !lock.is_command_executed(addon_id, req_cmd) {
124      return Err(anyhow!(
125        "Command '{}' requires '{}' to be run first. Run: oxide addon run {} {} {}",
126        command_name,
127        req_cmd,
128        addon_id,
129        req_cmd,
130        project_root.display()
131      ));
132    }
133  }
134
135  // 5b. Collect command-level inputs and add to context
136  let mut cmd_input_values: HashMap<String, String> = HashMap::new();
137  collect_inputs(&command.inputs, &mut cmd_input_values)?;
138  insert_with_derived(&mut tera_ctx, &cmd_input_values);
139
140  // 10. Execute steps
141  let addon_dir = ctx.paths.addons.join(addon_id);
142  let mut completed_rollbacks: Vec<Rollback> = Vec::new();
143
144  for (idx, step) in command.steps.iter().enumerate() {
145    let result = match step {
146      Step::Copy(s) => execute_copy(s, &addon_dir, project_root),
147      Step::Create(s) => execute_create(s, project_root, &tera_ctx),
148      Step::Inject(s) => execute_inject(s, project_root, &tera_ctx),
149      Step::Replace(s) => execute_replace(s, project_root, &tera_ctx),
150      Step::Append(s) => execute_append(s, project_root, &tera_ctx),
151      Step::Delete(s) => execute_delete(s, project_root),
152      Step::Rename(s) => execute_rename(s, project_root, &tera_ctx),
153      Step::Move(s) => execute_move(s, project_root, &tera_ctx),
154    };
155
156    match result {
157      Ok(rollbacks) => completed_rollbacks.extend(rollbacks),
158      Err(err) => {
159        eprintln!("Step {} failed: {}", idx + 1, err);
160        let choice = Select::new(
161          "How would you like to proceed?",
162          vec!["Keep changes made so far", "Rollback all changes"],
163        )
164        .prompt()?;
165
166        if choice == "Rollback all changes" {
167          for rollback in completed_rollbacks.into_iter().rev() {
168            let _ = apply_rollback(rollback);
169          }
170        }
171
172        return Err(anyhow!("Addon command failed at step {}: {}", idx + 1, err));
173      }
174    }
175  }
176
177  // 11. Update lock
178  let variant_id = detected_id.unwrap_or_else(|| "universal".to_string());
179  let mut commands_executed = lock
180    .addons
181    .iter()
182    .find(|e| e.id == addon_id)
183    .map(|e| e.commands_executed.clone())
184    .unwrap_or_default();
185  if !commands_executed.iter().any(|c| c == command_name) {
186    commands_executed.push(command_name.to_string());
187  }
188  lock.upsert_entry(LockEntry {
189    id: addon_id.to_string(),
190    version: manifest.version.clone(),
191    variant: variant_id,
192    commands_executed,
193  });
194  lock.save(project_root)?;
195
196  println!("✓ Command '{}' completed successfully.", command_name);
197  Ok(())
198}
199
200fn should_fallback_to_cached_manifest(error: &anyhow::Error) -> bool {
201  if error.to_string() == "You are not logged in yet." {
202    return true;
203  }
204
205  error.chain().any(|source| {
206    source
207      .downcast_ref::<reqwest::Error>()
208      .is_some_and(|reqwest_error| {
209        reqwest_error.is_connect()
210          || reqwest_error.is_timeout()
211          || reqwest_error.status().is_some_and(|status| {
212            matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
213          })
214      })
215  })
216}
217
218#[doc(hidden)]
219pub fn should_fallback_to_cached_manifest_for_tests(error: &anyhow::Error) -> bool {
220  should_fallback_to_cached_manifest(error)
221}
222
223fn rerun_prompt_message(
224  command_name: &str,
225  locked_version: Option<&str>,
226  current_version: &str,
227) -> Option<String> {
228  let locked_version = locked_version.filter(|version| !version.is_empty())?;
229  if locked_version == current_version {
230    return None;
231  }
232
233  Some(format!(
234    "Command '{}' was last run with v{} of this add-on. A new version (v{}) is available. Re-run it now?",
235    command_name, locked_version, current_version
236  ))
237}
238
239#[doc(hidden)]
240pub fn rerun_prompt_message_for_tests(
241  command_name: &str,
242  locked_version: Option<&str>,
243  current_version: &str,
244) -> Option<String> {
245  rerun_prompt_message(command_name, locked_version, current_version)
246}
247
248/// Prompts for a list of inputs and inserts results into `map`.
249fn collect_inputs(inputs: &[InputDef], map: &mut HashMap<String, String>) -> Result<()> {
250  for input in inputs {
251    let value = match input.input_type {
252      InputType::Text => {
253        let mut prompt = Text::new(&input.description);
254        if let Some(ref default) = input.default {
255          prompt = prompt.with_default(default);
256        }
257        prompt.prompt()?
258      }
259      InputType::Boolean => {
260        let default = input
261          .default
262          .as_deref()
263          .map(|d| d == "true")
264          .unwrap_or(false);
265        Confirm::new(&input.description)
266          .with_default(default)
267          .prompt()?
268          .to_string()
269      }
270      InputType::Select => Select::new(&input.description, input.options.clone())
271        .prompt()?
272        .to_string(),
273    };
274    map.insert(input.name.clone(), value);
275  }
276  Ok(())
277}
278
279/// Inserts every key/value from `map` into `ctx`, plus derived case variants:
280/// `{key}_pascal`, `{key}_camel`, `{key}_kebab`, `{key}_snake`.
281fn insert_with_derived(ctx: &mut tera::Context, map: &HashMap<String, String>) {
282  for (k, v) in map {
283    ctx.insert(k.as_str(), v);
284    ctx.insert(format!("{k}_pascal"), &to_pascal_case(v));
285    ctx.insert(format!("{k}_camel"), &to_camel_case(v));
286    ctx.insert(format!("{k}_kebab"), &to_kebab_case(v));
287    ctx.insert(format!("{k}_snake"), &to_snake_case(v));
288  }
289}
290
291fn apply_rollback(rollback: Rollback) -> Result<()> {
292  match rollback {
293    Rollback::DeleteCreatedFile { path } => {
294      let _ = std::fs::remove_file(path);
295    }
296    Rollback::RestoreFile { path, original } => {
297      std::fs::write(path, original)?;
298    }
299    Rollback::RenameFile { from, to } => {
300      std::fs::rename(from, to)?;
301    }
302  }
303  Ok(())
304}