Skip to main content

st/collab/
templates.rs

1//! Environment Templates - Pre-configured development environments
2//!
3//! Templates define what a user's space looks like:
4//! - Tools and languages installed
5//! - Shell configuration
6//! - Default environment variables
7//! - Container image (if using podman)
8
9use super::space::IsolationLevel;
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15/// A development environment template
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Template {
18    /// Template name (e.g., "rust-dev", "node-dev", "minimal")
19    pub name: String,
20
21    /// Human-readable description
22    pub description: String,
23
24    /// Base container image (for podman isolation)
25    pub image: Option<String>,
26
27    /// Default isolation level
28    #[serde(default)]
29    pub default_isolation: IsolationLevel,
30
31    /// Shell to use
32    #[serde(default = "default_shell")]
33    pub shell: String,
34
35    /// Environment variables
36    #[serde(default)]
37    pub env: Vec<(String, String)>,
38
39    /// Packages/tools to install
40    #[serde(default)]
41    pub packages: Vec<String>,
42
43    /// Shell init commands (run on space start)
44    #[serde(default)]
45    pub init_commands: Vec<String>,
46
47    /// Files to copy into the space
48    #[serde(default)]
49    pub files: HashMap<String, String>,
50
51    /// Author of the template
52    pub author: Option<String>,
53
54    /// Tags for discoverability
55    #[serde(default)]
56    pub tags: Vec<String>,
57}
58
59fn default_shell() -> String {
60    "/bin/bash".to_string()
61}
62
63impl Default for Template {
64    fn default() -> Self {
65        Template {
66            name: "minimal".to_string(),
67            description: "Minimal development environment".to_string(),
68            image: None,
69            default_isolation: IsolationLevel::None,
70            shell: default_shell(),
71            env: Vec::new(),
72            packages: Vec::new(),
73            init_commands: Vec::new(),
74            files: HashMap::new(),
75            author: None,
76            tags: vec!["minimal".to_string()],
77        }
78    }
79}
80
81impl Template {
82    /// Create a new template
83    pub fn new(name: &str, description: &str) -> Self {
84        Template {
85            name: name.to_string(),
86            description: description.to_string(),
87            ..Default::default()
88        }
89    }
90
91    /// Rust development template
92    pub fn rust_dev() -> Self {
93        Template {
94            name: "rust-dev".to_string(),
95            description: "Rust development with cargo, clippy, rustfmt".to_string(),
96            image: Some("rust:latest".to_string()),
97            default_isolation: IsolationLevel::None,
98            shell: "/bin/bash".to_string(),
99            env: vec![
100                ("CARGO_HOME".to_string(), "/usr/local/cargo".to_string()),
101                ("RUSTUP_HOME".to_string(), "/usr/local/rustup".to_string()),
102            ],
103            packages: vec![
104                "rust-analyzer".to_string(),
105                "cargo-watch".to_string(),
106                "cargo-edit".to_string(),
107            ],
108            init_commands: vec!["rustup component add clippy rustfmt".to_string()],
109            files: HashMap::new(),
110            author: Some("smart-tree".to_string()),
111            tags: vec!["rust".to_string(), "systems".to_string()],
112        }
113    }
114
115    /// Node.js development template
116    pub fn node_dev() -> Self {
117        Template {
118            name: "node-dev".to_string(),
119            description: "Node.js/TypeScript development with pnpm".to_string(),
120            image: Some("node:20".to_string()),
121            default_isolation: IsolationLevel::None,
122            shell: "/bin/bash".to_string(),
123            env: vec![("PNPM_HOME".to_string(), "/usr/local/pnpm".to_string())],
124            packages: vec![
125                "pnpm".to_string(),
126                "typescript".to_string(),
127                "tsx".to_string(),
128            ],
129            init_commands: vec!["corepack enable".to_string()],
130            files: HashMap::new(),
131            author: Some("smart-tree".to_string()),
132            tags: vec!["node".to_string(), "typescript".to_string(), "web".to_string()],
133        }
134    }
135
136    /// Python development template
137    pub fn python_dev() -> Self {
138        Template {
139            name: "python-dev".to_string(),
140            description: "Python development with uv and ruff".to_string(),
141            image: Some("python:3.12".to_string()),
142            default_isolation: IsolationLevel::None,
143            shell: "/bin/bash".to_string(),
144            env: vec![
145                ("UV_SYSTEM_PYTHON".to_string(), "1".to_string()),
146                ("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string()),
147            ],
148            packages: vec!["uv".to_string(), "ruff".to_string(), "mypy".to_string()],
149            init_commands: vec![],
150            files: HashMap::new(),
151            author: Some("smart-tree".to_string()),
152            tags: vec!["python".to_string(), "ml".to_string(), "data".to_string()],
153        }
154    }
155
156    /// AI assistant template (for Claude, etc.)
157    pub fn ai_assistant() -> Self {
158        Template {
159            name: "ai-assistant".to_string(),
160            description: "Environment for AI code assistants".to_string(),
161            image: None,
162            default_isolation: IsolationLevel::Namespace, // Light isolation for safety
163            shell: "/bin/bash".to_string(),
164            env: vec![
165                ("AI_MODE".to_string(), "1".to_string()),
166                ("TERM".to_string(), "xterm-256color".to_string()),
167            ],
168            packages: vec![], // AI brings its own tools via MCP
169            init_commands: vec![],
170            files: HashMap::new(),
171            author: Some("smart-tree".to_string()),
172            tags: vec!["ai".to_string(), "assistant".to_string(), "mcp".to_string()],
173        }
174    }
175
176    /// Load a template from a TOML file
177    pub fn load(path: &PathBuf) -> Result<Self> {
178        let content = std::fs::read_to_string(path)
179            .with_context(|| format!("Failed to read template: {:?}", path))?;
180        toml::from_str(&content).with_context(|| format!("Failed to parse template: {:?}", path))
181    }
182
183    /// Save template to a TOML file
184    pub fn save(&self, path: &PathBuf) -> Result<()> {
185        let content = toml::to_string_pretty(self)?;
186        std::fs::write(path, content)
187            .with_context(|| format!("Failed to write template: {:?}", path))?;
188        Ok(())
189    }
190}
191
192/// Template registry - manages available templates
193#[derive(Debug, Default)]
194pub struct TemplateRegistry {
195    templates: HashMap<String, Template>,
196    search_paths: Vec<PathBuf>,
197}
198
199impl TemplateRegistry {
200    /// Create a new registry with default templates
201    pub fn new() -> Self {
202        let mut registry = TemplateRegistry {
203            templates: HashMap::new(),
204            search_paths: vec![],
205        };
206
207        // Register built-in templates
208        registry.register(Template::default());
209        registry.register(Template::rust_dev());
210        registry.register(Template::node_dev());
211        registry.register(Template::python_dev());
212        registry.register(Template::ai_assistant());
213
214        registry
215    }
216
217    /// Add a search path for template files
218    pub fn add_search_path(&mut self, path: PathBuf) {
219        self.search_paths.push(path);
220    }
221
222    /// Register a template
223    pub fn register(&mut self, template: Template) {
224        self.templates.insert(template.name.clone(), template);
225    }
226
227    /// Get a template by name
228    pub fn get(&self, name: &str) -> Option<&Template> {
229        self.templates.get(name)
230    }
231
232    /// List all available templates
233    pub fn list(&self) -> Vec<&Template> {
234        self.templates.values().collect()
235    }
236
237    /// Search templates by tag
238    pub fn search_by_tag(&self, tag: &str) -> Vec<&Template> {
239        self.templates
240            .values()
241            .filter(|t| t.tags.iter().any(|t| t.contains(tag)))
242            .collect()
243    }
244
245    /// Load templates from all search paths
246    pub fn load_all(&mut self) -> Result<usize> {
247        let mut count = 0;
248        for path in self.search_paths.clone() {
249            if path.is_dir() {
250                for entry in std::fs::read_dir(&path)? {
251                    let entry = entry?;
252                    let file_path = entry.path();
253                    if file_path.extension().is_some_and(|e| e == "toml") {
254                        if let Ok(template) = Template::load(&file_path) {
255                            self.register(template);
256                            count += 1;
257                        }
258                    }
259                }
260            }
261        }
262        Ok(count)
263    }
264
265    /// Create a template from the current environment
266    pub fn capture_current(name: &str, description: &str) -> Result<Template> {
267        let mut template = Template::new(name, description);
268
269        // Capture current shell
270        if let Ok(shell) = std::env::var("SHELL") {
271            template.shell = shell;
272        }
273
274        // Capture relevant environment variables
275        let capture_vars = ["CARGO_HOME", "RUSTUP_HOME", "PNPM_HOME", "GOPATH", "PYENV_ROOT"];
276        for var in capture_vars {
277            if let Ok(value) = std::env::var(var) {
278                template.env.push((var.to_string(), value));
279            }
280        }
281
282        Ok(template)
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_default_template() {
292        let template = Template::default();
293        assert_eq!(template.name, "minimal");
294        assert_eq!(template.shell, "/bin/bash");
295    }
296
297    #[test]
298    fn test_rust_dev_template() {
299        let template = Template::rust_dev();
300        assert_eq!(template.name, "rust-dev");
301        assert!(template.tags.contains(&"rust".to_string()));
302    }
303
304    #[test]
305    fn test_registry() {
306        let registry = TemplateRegistry::new();
307        assert!(registry.get("minimal").is_some());
308        assert!(registry.get("rust-dev").is_some());
309        assert!(registry.get("node-dev").is_some());
310    }
311
312    #[test]
313    fn test_search_by_tag() {
314        let registry = TemplateRegistry::new();
315        let web = registry.search_by_tag("web");
316        assert!(!web.is_empty());
317    }
318}