1use super::space::IsolationLevel;
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Template {
18 pub name: String,
20
21 pub description: String,
23
24 pub image: Option<String>,
26
27 #[serde(default)]
29 pub default_isolation: IsolationLevel,
30
31 #[serde(default = "default_shell")]
33 pub shell: String,
34
35 #[serde(default)]
37 pub env: Vec<(String, String)>,
38
39 #[serde(default)]
41 pub packages: Vec<String>,
42
43 #[serde(default)]
45 pub init_commands: Vec<String>,
46
47 #[serde(default)]
49 pub files: HashMap<String, String>,
50
51 pub author: Option<String>,
53
54 #[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 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 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 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 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 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, 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![], 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 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 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#[derive(Debug, Default)]
194pub struct TemplateRegistry {
195 templates: HashMap<String, Template>,
196 search_paths: Vec<PathBuf>,
197}
198
199impl TemplateRegistry {
200 pub fn new() -> Self {
202 let mut registry = TemplateRegistry {
203 templates: HashMap::new(),
204 search_paths: vec![],
205 };
206
207 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 pub fn add_search_path(&mut self, path: PathBuf) {
219 self.search_paths.push(path);
220 }
221
222 pub fn register(&mut self, template: Template) {
224 self.templates.insert(template.name.clone(), template);
225 }
226
227 pub fn get(&self, name: &str) -> Option<&Template> {
229 self.templates.get(name)
230 }
231
232 pub fn list(&self) -> Vec<&Template> {
234 self.templates.values().collect()
235 }
236
237 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 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 pub fn capture_current(name: &str, description: &str) -> Result<Template> {
267 let mut template = Template::new(name, description);
268
269 if let Ok(shell) = std::env::var("SHELL") {
271 template.shell = shell;
272 }
273
274 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}