1use std::collections::HashMap;
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use tabled::{Table, Tabled};
8
9use crate::cli::{TemplateArgs, TemplateCommand};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ServiceDef {
14 pub image: String,
16 pub port: u16,
18 #[serde(default = "default_true")]
20 pub public: bool,
21 #[serde(default = "default_health_path")]
23 pub health_path: String,
24 #[serde(default)]
26 pub env: HashMap<String, String>,
27 #[serde(default)]
29 pub command: Option<String>,
30 #[serde(default)]
32 pub volume: Option<String>,
33}
34
35fn default_true() -> bool {
36 true
37}
38
39fn default_health_path() -> String {
40 "/health".to_string()
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AgentTemplate {
46 pub meta: TemplateMeta,
48 pub services: HashMap<String, ServiceDef>,
50 #[serde(default)]
52 pub shared_env: HashMap<String, String>,
53 #[serde(default)]
55 pub scaling: ScalingConfig,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct TemplateMeta {
60 pub name: String,
62 pub description: String,
64 pub use_case: String,
66 #[serde(default = "default_version")]
68 pub version: String,
69}
70
71fn default_version() -> String {
72 "0.1.0".to_string()
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ScalingConfig {
78 #[serde(default = "default_min_replicas")]
80 pub min_replicas: u32,
81 #[serde(default = "default_max_replicas")]
83 pub max_replicas: u32,
84 #[serde(default = "default_scale_down_mode")]
86 pub scale_down_mode: String,
87 #[serde(default = "default_scale_up_mode")]
89 pub scale_up_mode: String,
90}
91
92fn default_min_replicas() -> u32 {
93 1
94}
95fn default_max_replicas() -> u32 {
96 3
97}
98fn default_scale_down_mode() -> String {
99 "conserving".to_string()
100}
101fn default_scale_up_mode() -> String {
102 "sovereign".to_string()
103}
104
105impl Default for ScalingConfig {
106 fn default() -> Self {
107 Self {
108 min_replicas: default_min_replicas(),
109 max_replicas: default_max_replicas(),
110 scale_down_mode: default_scale_down_mode(),
111 scale_up_mode: default_scale_up_mode(),
112 }
113 }
114}
115
116const CODING_AGENT_TOML: &str = include_str!("../templates/coding-agent.toml");
119const DATA_AGENT_TOML: &str = include_str!("../templates/data-agent.toml");
120const SUPPORT_AGENT_TOML: &str = include_str!("../templates/support-agent.toml");
121
122pub fn load_template(name: &str, custom_path: Option<&str>) -> Result<AgentTemplate> {
124 if let Some(path) = custom_path {
126 let content = std::fs::read_to_string(path)
127 .with_context(|| format!("failed to read template file: {path}"))?;
128 return toml::from_str(&content)
129 .with_context(|| format!("failed to parse template file: {path}"));
130 }
131
132 let toml_str = match name {
134 "coding-agent" => CODING_AGENT_TOML,
135 "data-agent" => DATA_AGENT_TOML,
136 "support-agent" => SUPPORT_AGENT_TOML,
137 _ => {
138 let home = dirs::home_dir().context("cannot determine home directory")?;
140 let path = home
141 .join(".life")
142 .join("templates")
143 .join(format!("{name}.toml"));
144 if path.exists() {
145 let content = std::fs::read_to_string(&path)
146 .with_context(|| format!("failed to read {}", path.display()))?;
147 return toml::from_str(&content)
148 .with_context(|| format!("failed to parse {}", path.display()));
149 }
150 anyhow::bail!(
151 "unknown template '{name}'. Available: coding-agent, data-agent, support-agent.\n\
152 Custom templates: place TOML files in ~/.life/templates/ or use --template-path."
153 );
154 }
155 };
156
157 toml::from_str(toml_str).with_context(|| format!("failed to parse built-in template: {name}"))
158}
159
160pub fn list_templates() -> Vec<AgentTemplate> {
162 let mut templates = vec![];
163
164 for toml_str in [CODING_AGENT_TOML, DATA_AGENT_TOML, SUPPORT_AGENT_TOML] {
165 if let Ok(t) = toml::from_str::<AgentTemplate>(toml_str) {
166 templates.push(t);
167 }
168 }
169
170 if let Some(home) = dirs::home_dir() {
172 let custom_dir = home.join(".life").join("templates");
173 if custom_dir.is_dir() {
174 if let Ok(entries) = std::fs::read_dir(custom_dir) {
175 for entry in entries.flatten() {
176 let path = entry.path();
177 if path.extension().is_some_and(|e| e == "toml") {
178 if let Ok(content) = std::fs::read_to_string(&path) {
179 if let Ok(t) = toml::from_str::<AgentTemplate>(&content) {
180 templates.push(t);
181 }
182 }
183 }
184 }
185 }
186 }
187 }
188
189 templates
190}
191
192#[derive(Tabled)]
195struct TemplateRow {
196 #[tabled(rename = "Name")]
197 name: String,
198 #[tabled(rename = "Description")]
199 description: String,
200 #[tabled(rename = "Services")]
201 services: String,
202 #[tabled(rename = "Use Case")]
203 use_case: String,
204}
205
206pub fn run(args: TemplateArgs) -> Result<()> {
207 match args.command {
208 TemplateCommand::List => {
209 let templates = list_templates();
210 if templates.is_empty() {
211 println!("No templates found.");
212 return Ok(());
213 }
214
215 let rows: Vec<TemplateRow> = templates
216 .iter()
217 .map(|t| {
218 let svc_names: Vec<&str> = t.services.keys().map(String::as_str).collect();
219 TemplateRow {
220 name: t.meta.name.clone(),
221 description: t.meta.description.clone(),
222 services: svc_names.join(", "),
223 use_case: t.meta.use_case.clone(),
224 }
225 })
226 .collect();
227
228 println!("{}", Table::new(rows));
229 Ok(())
230 }
231 TemplateCommand::Show { name } => {
232 let template = load_template(&name, None)?;
233 println!("{}", toml::to_string_pretty(&template)?);
234 Ok(())
235 }
236 }
237}