1use anyhow::{anyhow, Context, Result};
2use foundry_archetypes::{as_artifacts, plan_files};
3use foundry_conventions::{resolve_config, ConfigLayers};
4use foundry_schema::{load_partial_from_file, PartialConfig};
5use foundry_template::renderer;
6use foundry_types::{
7 Archetype, Artifact, CheckReport, DecisionTrace, JsonEnvelope, ResolvedConfigReport,
8 TemplateEngineKind,
9};
10use foundry_validation::{
11 doctor_checks, has_failures, post_gen_checks, schema_checks, skipped_checks,
12};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use std::collections::BTreeMap;
16use std::env;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20const STATE_FILE: &str = ".foundry-session.json";
21
22#[derive(Debug, Clone, Default)]
23pub struct RunOptions {
24 pub project_dir: PathBuf,
25 pub archetype_override: Option<Archetype>,
26 pub project_name_override: Option<String>,
27 pub template_engine_override: Option<TemplateEngineKind>,
28 pub profile_override: Option<String>,
29 pub run_post_gen_checks_override: Option<bool>,
30 pub force: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34struct SessionState {
35 plan_ok: bool,
36 doctor_ok: bool,
37}
38
39pub fn plan(opts: &RunOptions) -> Result<JsonEnvelope> {
40 let (cfg, trace) = resolved_config(opts)?;
41 let files = plan_files(&cfg);
42 write_state(
43 &opts.project_dir,
44 SessionState {
45 plan_ok: true,
46 doctor_ok: read_state(&opts.project_dir).doctor_ok,
47 },
48 )?;
49 let mut env = JsonEnvelope::ok("plan", as_artifacts(&files), trace);
50 env.resolved_config = Some(to_resolved_config_report(&cfg));
51 Ok(env)
52}
53
54pub fn doctor(opts: &RunOptions) -> Result<JsonEnvelope> {
55 let (cfg, trace) = resolved_config(opts)?;
56 let mut checks = doctor_checks();
57 checks.extend(schema_checks(&opts.project_dir));
58 let mut env = JsonEnvelope::ok("doctor", vec![], trace);
59 env.resolved_config = Some(to_resolved_config_report(&cfg));
60 env.artifacts = checks
61 .iter()
62 .map(|c| Artifact {
63 path: c.name.clone(),
64 kind: format!("{:?}", c.status).to_lowercase(),
65 })
66 .collect();
67 env.checks = checks.iter().map(to_check_report).collect();
68
69 let all_ok = !has_failures(&checks);
70 write_state(
71 &opts.project_dir,
72 SessionState {
73 plan_ok: read_state(&opts.project_dir).plan_ok,
74 doctor_ok: all_ok,
75 },
76 )?;
77
78 if !all_ok {
79 env.status = "error".into();
80 env.errors.push("doctor checks failed".into());
81 }
82 for skipped in skipped_checks(&checks) {
83 env.warnings
84 .push(format!("doctor check skipped: {}", skipped));
85 }
86
87 Ok(env)
88}
89
90pub fn explain(opts: &RunOptions) -> Result<JsonEnvelope> {
91 let (cfg, trace) = resolved_config(opts)?;
92 let mut env = JsonEnvelope::ok("explain", vec![], trace);
93 env.resolved_config = Some(to_resolved_config_report(&cfg));
94 Ok(env)
95}
96
97pub fn apply(opts: &RunOptions) -> Result<JsonEnvelope> {
98 let (cfg, trace) = resolved_config(opts)?;
99 let state = read_state(&opts.project_dir);
100 if !(opts.force || (state.plan_ok && state.doctor_ok)) {
101 return Ok(JsonEnvelope::error(
102 "apply",
103 "apply requires successful plan and doctor in current context; rerun plan+doctor or use --force".into(),
104 ));
105 }
106
107 let files = plan_files(&cfg);
108 let mut artifact_list = vec![];
109 for file in &files {
110 let content = render_template_from_project(&opts.project_dir, &cfg, &file.template_key)?;
111 let path = opts.project_dir.join(&file.output_path);
112 if let Some(parent) = path.parent() {
113 fs::create_dir_all(parent)
114 .with_context(|| format!("failed to create {}", parent.display()))?;
115 }
116 fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))?;
117 artifact_list.push(Artifact {
118 path: file.output_path.clone(),
119 kind: file.kind.clone(),
120 });
121 }
122
123 let post = post_gen_checks(&opts.project_dir, cfg.run_post_gen_checks);
124 let mut warnings = vec![];
125 let mut errors = vec![];
126 for p in &post {
127 if matches!(p.status, foundry_types::CheckStatus::Failed) {
128 errors.push(format!("post-gen check failed: {}", p.name));
129 } else if matches!(p.status, foundry_types::CheckStatus::Skipped) {
130 warnings.push(format!("post-gen check skipped: {}", p.name));
131 }
132 }
133
134 write_lock_and_audit(&opts.project_dir, &cfg, &trace, &artifact_list)?;
135
136 let mut env = JsonEnvelope::ok("apply", artifact_list, trace);
137 env.resolved_config = Some(to_resolved_config_report(&cfg));
138 env.checks = post.iter().map(to_check_report).collect();
139 if !errors.is_empty() {
140 env.status = "error".into();
141 env.errors = errors;
142 }
143 env.warnings = warnings;
144 Ok(env)
145}
146
147fn resolved_config(
148 opts: &RunOptions,
149) -> Result<(foundry_schema::FoundryConfig, Vec<DecisionTrace>)> {
150 let project = load_if_exists(&opts.project_dir.join("foundry.toml"))?;
151 let user_companion = load_if_exists(&default_companion_path())?;
152 let project_companion = load_if_exists(&opts.project_dir.join("companion.toml"))?;
153 let companion = merge_partial(user_companion, project_companion);
154 let profile_name = resolve_profile_name(&project, &companion, &opts.profile_override);
155 let profile = load_if_exists(
156 &opts
157 .project_dir
158 .join("profiles")
159 .join(format!("{}.toml", profile_name)),
160 )?;
161 let explicit = PartialConfig {
162 archetype: opts.archetype_override.clone(),
163 project_name: opts.project_name_override.clone(),
164 template_engine: opts.template_engine_override.clone(),
165 profile: opts.profile_override.clone(),
166 run_post_gen_checks: opts.run_post_gen_checks_override,
167 ..Default::default()
168 };
169
170 Ok(resolve_config(ConfigLayers {
171 profile,
172 project,
173 companion,
174 explicit,
175 }))
176}
177
178fn resolve_profile_name(
179 project: &PartialConfig,
180 companion: &PartialConfig,
181 explicit_profile: &Option<String>,
182) -> String {
183 explicit_profile
184 .clone()
185 .or(companion.profile.clone())
186 .or(project.profile.clone())
187 .unwrap_or_else(|| "default".to_string())
188}
189
190fn default_companion_path() -> PathBuf {
191 if let Ok(path) = env::var("FOUNDRY_COMPANION_PATH") {
192 return PathBuf::from(path);
193 }
194
195 if let Ok(home) = env::var("HOME") {
196 return PathBuf::from(home).join(".config/foundry/companion.toml");
197 }
198
199 PathBuf::from(".config/foundry/companion.toml")
200}
201
202fn load_if_exists(path: &Path) -> Result<PartialConfig> {
203 if path.exists() {
204 load_partial_from_file(path)
205 } else {
206 Ok(PartialConfig::default())
207 }
208}
209
210fn merge_partial(base: PartialConfig, overlay: PartialConfig) -> PartialConfig {
211 PartialConfig {
212 project_name: overlay.project_name.or(base.project_name),
213 archetype: overlay.archetype.or(base.archetype),
214 template_engine: overlay.template_engine.or(base.template_engine),
215 profile: overlay.profile.or(base.profile),
216 run_post_gen_checks: overlay.run_post_gen_checks.or(base.run_post_gen_checks),
217 }
218}
219
220fn render_template_from_project(
221 project_dir: &Path,
222 cfg: &foundry_schema::FoundryConfig,
223 template_key: &str,
224) -> Result<String> {
225 let fs_template = map_template_key_to_project_template_path(template_key)
226 .map(|rel| project_dir.join(rel))
227 .filter(|p| p.exists())
228 .map(fs::read_to_string)
229 .transpose()?;
230
231 let template_text = fs_template.unwrap_or_else(|| {
232 template_map()
233 .get(template_key)
234 .map(|v| (*v).to_string())
235 .unwrap_or_default()
236 });
237
238 if template_text.is_empty() {
239 return Err(anyhow!("missing template key: {}", template_key));
240 }
241
242 let context = json!({
243 "project_name": cfg.project_name,
244 "archetype": cfg.archetype.to_string()
245 });
246
247 renderer(&cfg.template_engine).render(&template_text, &context)
248}
249
250fn template_map() -> BTreeMap<&'static str, &'static str> {
251 let mut m = BTreeMap::new();
252 m.insert(
253 "common/README.md.tpl",
254 "# {{ project_name }}\n\nGenerated by Rust Foundry ({{ archetype }}).\n",
255 );
256 m.insert(
257 "web/src/main.rs.tpl",
258 "fn main() { println!(\"web service: {{ project_name }}\"); }\n",
259 );
260 m.insert(
261 "web/Cargo.toml.tpl",
262 "[package]\nname = \"{{ project_name }}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n",
263 );
264 m.insert(
265 "tui/src/main.rs.tpl",
266 "fn main() { println!(\"tui app: {{ project_name }}\"); }\n",
267 );
268 m.insert(
269 "tui/Cargo.toml.tpl",
270 "[package]\nname = \"{{ project_name }}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n",
271 );
272 m.insert(
273 "tooling/src/main.rs.tpl",
274 "fn main() { println!(\"tooling app: {{ project_name }}\"); }\n",
275 );
276 m.insert(
277 "tooling/Cargo.toml.tpl",
278 "[package]\nname = \"{{ project_name }}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n",
279 );
280 m
281}
282
283fn map_template_key_to_project_template_path(template_key: &str) -> Option<&'static str> {
284 match template_key {
285 "web/src/main.rs.tpl" => Some("templates/web/main.rs.tpl"),
286 "web/Cargo.toml.tpl" => Some("templates/web/Cargo.toml.tpl"),
287 "tui/src/main.rs.tpl" => Some("templates/tui/main.rs.tpl"),
288 "tui/Cargo.toml.tpl" => Some("templates/tui/Cargo.toml.tpl"),
289 "tooling/src/main.rs.tpl" => Some("templates/tooling/main.rs.tpl"),
290 "tooling/Cargo.toml.tpl" => Some("templates/tooling/Cargo.toml.tpl"),
291 _ => None,
292 }
293}
294
295fn write_lock_and_audit(
296 project_dir: &Path,
297 cfg: &foundry_schema::FoundryConfig,
298 trace: &[DecisionTrace],
299 artifacts: &[Artifact],
300) -> Result<()> {
301 let lock = json!({
302 "config": cfg,
303 "artifacts": artifacts,
304 });
305 fs::write(
306 project_dir.join("foundry.lock.json"),
307 serde_json::to_string_pretty(&lock)?,
308 )?;
309
310 let audit = json!({
311 "config": cfg,
312 "artifacts": artifacts,
313 "decision_trace": trace,
314 });
315 fs::write(
316 project_dir.join("audit.log"),
317 serde_json::to_string_pretty(&audit)?,
318 )?;
319 Ok(())
320}
321
322fn read_state(project_dir: &Path) -> SessionState {
323 let path = project_dir.join(STATE_FILE);
324 if let Ok(content) = fs::read_to_string(path) {
325 serde_json::from_str(&content).unwrap_or_default()
326 } else {
327 SessionState::default()
328 }
329}
330
331fn write_state(project_dir: &Path, state: SessionState) -> Result<()> {
332 let path = project_dir.join(STATE_FILE);
333 fs::write(path, serde_json::to_vec(&state)?)?;
334 Ok(())
335}
336
337fn to_check_report(c: &foundry_validation::CheckResult) -> CheckReport {
338 CheckReport {
339 name: c.name.clone(),
340 status: format!("{:?}", c.status).to_lowercase(),
341 detail: c.detail.clone(),
342 }
343}
344
345fn to_resolved_config_report(cfg: &foundry_schema::FoundryConfig) -> ResolvedConfigReport {
346 ResolvedConfigReport {
347 project_name: cfg.project_name.clone(),
348 archetype: cfg.archetype.to_string(),
349 template_engine: format!("{:?}", cfg.template_engine).to_lowercase(),
350 profile: cfg.profile.clone(),
351 run_post_gen_checks: cfg.run_post_gen_checks,
352 }
353}