Skip to main content

foundry_core/
lib.rs

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}