Skip to main content

dot/
packages.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7
8use crate::config::Config;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ExtensionManifest {
12    pub name: String,
13    #[serde(default)]
14    pub description: String,
15    #[serde(default)]
16    pub version: String,
17    #[serde(default)]
18    pub tools: HashMap<String, ManifestTool>,
19    #[serde(default)]
20    pub commands: HashMap<String, ManifestCommand>,
21    #[serde(default)]
22    pub hooks: HashMap<String, ManifestHook>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ManifestTool {
27    pub description: String,
28    pub command: String,
29    #[serde(default = "default_schema")]
30    pub schema: serde_json::Value,
31    #[serde(default = "default_timeout")]
32    pub timeout: u64,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ManifestCommand {
37    pub description: String,
38    pub command: String,
39    #[serde(default = "default_timeout")]
40    pub timeout: u64,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ManifestHook {
45    pub command: String,
46    #[serde(default = "default_timeout")]
47    pub timeout: u64,
48}
49
50fn default_schema() -> serde_json::Value {
51    serde_json::json!({
52        "type": "object",
53        "properties": {},
54        "required": []
55    })
56}
57
58fn default_timeout() -> u64 {
59    30
60}
61
62pub struct InstalledExtension {
63    pub manifest: ExtensionManifest,
64    pub path: PathBuf,
65}
66
67fn extensions_dir() -> PathBuf {
68    Config::config_dir().join("extensions")
69}
70
71pub fn discover() -> Vec<InstalledExtension> {
72    let dir = extensions_dir();
73    if !dir.exists() {
74        return Vec::new();
75    }
76    let mut results = Vec::new();
77    let entries = match std::fs::read_dir(&dir) {
78        Ok(e) => e,
79        Err(_) => return Vec::new(),
80    };
81    for entry in entries.flatten() {
82        let path = entry.path();
83        if !path.is_dir() {
84            continue;
85        }
86        let manifest_path = path.join("extension.toml");
87        if !manifest_path.exists() {
88            continue;
89        }
90        match load_manifest(&manifest_path) {
91            Ok(manifest) => {
92                results.push(InstalledExtension {
93                    manifest,
94                    path: path.clone(),
95                });
96            }
97            Err(e) => {
98                tracing::warn!("Failed to load extension from {}: {}", path.display(), e);
99            }
100        }
101    }
102    results
103}
104
105fn load_manifest(path: &Path) -> Result<ExtensionManifest> {
106    let content =
107        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
108    toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))
109}
110
111/// Resolve a command path relative to the extension directory.
112fn resolve_command(ext_path: &Path, command: &str) -> String {
113    let script = ext_path.join(command);
114    if script.exists() {
115        script.to_string_lossy().to_string()
116    } else {
117        command.to_string()
118    }
119}
120
121/// Convert discovered extensions into config entries that can be merged into the main config.
122pub fn merge_into_config(config: &mut crate::config::Config) {
123    for ext in discover() {
124        let dir = &ext.path;
125        for (name, tool) in &ext.manifest.tools {
126            let key = format!("{}:{}", ext.manifest.name, name);
127            config
128                .custom_tools
129                .entry(key)
130                .or_insert_with(|| crate::config::CustomToolConfig {
131                    description: tool.description.clone(),
132                    command: resolve_command(dir, &tool.command),
133                    schema: tool.schema.clone(),
134                    timeout: tool.timeout,
135                });
136        }
137        for (name, cmd) in &ext.manifest.commands {
138            let key = format!("{}:{}", ext.manifest.name, name);
139            config
140                .commands
141                .entry(key)
142                .or_insert_with(|| crate::config::CommandConfig {
143                    description: cmd.description.clone(),
144                    command: resolve_command(dir, &cmd.command),
145                    timeout: cmd.timeout,
146                });
147        }
148        for (event_name, hook) in &ext.manifest.hooks {
149            config
150                .hooks
151                .entry(event_name.clone())
152                .or_insert_with(|| crate::config::HookConfig {
153                    command: resolve_command(dir, &hook.command),
154                    timeout: hook.timeout,
155                });
156        }
157    }
158}
159
160pub fn install(source: &str) -> Result<String> {
161    let dir = extensions_dir();
162    std::fs::create_dir_all(&dir)
163        .with_context(|| format!("creating extensions dir {}", dir.display()))?;
164
165    let name = source
166        .rsplit('/')
167        .next()
168        .unwrap_or("extension")
169        .trim_end_matches(".git");
170    let target = dir.join(name);
171
172    if target.exists() {
173        bail!(
174            "Extension '{}' already installed at {}",
175            name,
176            target.display()
177        );
178    }
179
180    let output = Command::new("git")
181        .args(["clone", "--depth", "1", source])
182        .arg(&target)
183        .output()
184        .context("running git clone")?;
185
186    if !output.status.success() {
187        let stderr = String::from_utf8_lossy(&output.stderr);
188        bail!("git clone failed: {}", stderr.trim());
189    }
190
191    let manifest_path = target.join("extension.toml");
192    if !manifest_path.exists() {
193        let _ = std::fs::remove_dir_all(&target);
194        bail!(
195            "No extension.toml found in {}. Not a valid dot extension.",
196            source
197        );
198    }
199
200    let manifest = load_manifest(&manifest_path)?;
201    Ok(format!(
202        "Installed '{}' ({} tools, {} commands, {} hooks)",
203        manifest.name,
204        manifest.tools.len(),
205        manifest.commands.len(),
206        manifest.hooks.len(),
207    ))
208}
209
210pub fn uninstall(name: &str) -> Result<String> {
211    let target = extensions_dir().join(name);
212    if !target.exists() {
213        bail!("Extension '{}' not found", name);
214    }
215    std::fs::remove_dir_all(&target).with_context(|| format!("removing {}", target.display()))?;
216    Ok(format!("Removed extension '{}'", name))
217}
218
219pub fn list() -> Vec<(String, String, PathBuf)> {
220    discover()
221        .into_iter()
222        .map(|e| (e.manifest.name, e.manifest.description, e.path))
223        .collect()
224}