Skip to main content

greentic_bundle/build/
mod.rs

1pub mod export;
2pub mod lock;
3pub mod manifest;
4pub mod plan;
5pub mod squashfs;
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result, bail};
10use greentic_bundle_reader::{
11    BundleLock as ReaderBundleLock, BundleManifest as ReaderBundleManifest, OpenedBundle,
12};
13use serde::Serialize;
14use tempfile::TempDir;
15
16pub const FUTURE_ARTIFACT_EXTENSION: &str = ".gtbundle";
17pub const BUILD_STATE_DIR: &str = "state/build";
18pub const BUILD_FORMAT_VERSION: &str = "gtbundle-v1";
19
20#[derive(Debug, Clone, Serialize)]
21pub struct BuildResult {
22    pub artifact_path: String,
23    pub build_dir: String,
24    pub manifest_path: String,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct DoctorReport {
29    pub target: String,
30    pub ok: bool,
31    pub checks: Vec<DoctorCheck>,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct DoctorCheck {
36    pub name: String,
37    pub ok: bool,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub details: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct InspectReport {
44    pub target: String,
45    pub kind: String,
46    pub manifest: ReaderBundleManifest,
47    pub lock: ReaderBundleLock,
48    pub runtime_surface: greentic_bundle_reader::BundleRuntimeSurface,
49}
50
51pub fn build_workspace(root: &Path, output: Option<&Path>, dry_run: bool) -> Result<BuildResult> {
52    let state = plan::build_state(root)?;
53    let artifact = output
54        .map(|path| path.to_path_buf())
55        .unwrap_or_else(|| default_artifact_path(root, &state.manifest.bundle_id));
56    let export_plan = export::export_plan(&state, &artifact);
57    if dry_run {
58        return Ok(BuildResult {
59            artifact_path: export_plan.artifact_path,
60            build_dir: export_plan.build_dir,
61            manifest_path: export_plan.manifest_path,
62        });
63    }
64    export::write_build_outputs(&state, &artifact)
65}
66
67pub fn export_build_dir(build_dir: &Path, output: &Path, dry_run: bool) -> Result<BuildResult> {
68    let state = plan::load_build_state(build_dir)?;
69    let export_plan = export::export_plan(&state, output);
70    if dry_run {
71        return Ok(BuildResult {
72            artifact_path: export_plan.artifact_path,
73            build_dir: export_plan.build_dir,
74            manifest_path: export_plan.manifest_path,
75        });
76    }
77    export::write_build_outputs(&state, output)
78}
79
80pub fn inspect_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<InspectReport> {
81    match (root, artifact) {
82        (Some(root), None) => {
83            let opened = open_workspace_build_dir(root)?;
84            Ok(InspectReport {
85                target: root.display().to_string(),
86                kind: "workspace".to_string(),
87                manifest: opened.manifest.clone(),
88                lock: opened.lock.clone(),
89                runtime_surface: opened.runtime_surface(),
90            })
91        }
92        (None, Some(artifact)) => inspect_artifact(artifact),
93        _ => bail!("inspect requires exactly one of workspace root or artifact path"),
94    }
95}
96
97pub fn doctor_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<DoctorReport> {
98    match (root, artifact) {
99        (Some(root), None) => doctor_workspace(root),
100        (None, Some(artifact)) => doctor_artifact(artifact),
101        _ => bail!("doctor requires exactly one of workspace root or artifact path"),
102    }
103}
104
105fn doctor_workspace(root: &Path) -> Result<DoctorReport> {
106    let state = plan::build_state(root)?;
107    let drift_ok = lock::lock_matches_manifest(&state.lock, &state.manifest);
108    let reader_validation = open_workspace_build_dir(root);
109    let reader_ok = reader_validation.is_ok();
110    let checks = vec![
111        DoctorCheck {
112            name: "bundle.yaml".to_string(),
113            ok: root.join(crate::project::WORKSPACE_ROOT_FILE).exists(),
114            details: None,
115        },
116        DoctorCheck {
117            name: "bundle.lock.json".to_string(),
118            ok: root.join(crate::project::LOCK_FILE).exists(),
119            details: None,
120        },
121        DoctorCheck {
122            name: "lock drift".to_string(),
123            ok: drift_ok,
124            details: (!drift_ok).then_some(
125                "bundle.lock.json does not match current workspace manifest inputs".to_string(),
126            ),
127        },
128        DoctorCheck {
129            name: "reader validation".to_string(),
130            ok: reader_ok,
131            details: if reader_ok {
132                Some("workspace manifest/lock satisfy reader contract".to_string())
133            } else {
134                Some(
135                    reader_validation
136                        .err()
137                        .map(|error| error.to_string())
138                        .unwrap_or_else(|| {
139                            "workspace manifest/lock do not satisfy reader contract".to_string()
140                        }),
141                )
142            },
143        },
144    ];
145    Ok(DoctorReport {
146        target: root.display().to_string(),
147        ok: checks.iter().all(|check| check.ok),
148        checks,
149    })
150}
151
152fn doctor_artifact(artifact: &Path) -> Result<DoctorReport> {
153    let opened = greentic_bundle_reader::open_artifact(artifact)
154        .with_context(|| format!("open artifact {}", artifact.display()))?;
155    let checks = vec![
156        DoctorCheck {
157            name: "artifact exists".to_string(),
158            ok: artifact.exists(),
159            details: None,
160        },
161        DoctorCheck {
162            name: "manifest embedded".to_string(),
163            ok: !opened.manifest.bundle_id.is_empty(),
164            details: None,
165        },
166        DoctorCheck {
167            name: "lock embedded".to_string(),
168            ok: !opened.lock.bundle_id.is_empty(),
169            details: None,
170        },
171        DoctorCheck {
172            name: "reader validation".to_string(),
173            ok: true,
174            details: Some(format!(
175                "{} opened by {} reader",
176                opened.format_version,
177                opened.source_kind.as_str()
178            )),
179        },
180    ];
181    Ok(DoctorReport {
182        target: artifact.display().to_string(),
183        ok: checks.iter().all(|check| check.ok),
184        checks,
185    })
186}
187
188fn inspect_artifact(artifact: &Path) -> Result<InspectReport> {
189    let opened = greentic_bundle_reader::open_artifact(artifact)
190        .with_context(|| format!("open artifact {}", artifact.display()))?;
191    Ok(InspectReport {
192        target: artifact.display().to_string(),
193        kind: "artifact".to_string(),
194        manifest: opened.manifest.clone(),
195        lock: opened.lock.clone(),
196        runtime_surface: opened.runtime_surface(),
197    })
198}
199
200pub fn default_artifact_path(root: &Path, bundle_id: &str) -> PathBuf {
201    root.join("dist")
202        .join(format!("{bundle_id}{FUTURE_ARTIFACT_EXTENSION}"))
203}
204
205fn open_workspace_build_dir(root: &Path) -> Result<OpenedBundle> {
206    let state = plan::build_state(root)?;
207    let staging_dir = temp_build_dir(&state)?;
208    greentic_bundle_reader::open_build_dir_with_source(
209        staging_dir.path(),
210        root.display().to_string(),
211    )
212    .map_err(|error| anyhow::anyhow!(error.to_string()))
213}
214
215fn temp_build_dir(state: &plan::BuildState) -> Result<TempDir> {
216    let staging_dir = tempfile::tempdir().context("create temporary normalized build dir")?;
217    export::write_normalized_build_dir(state, staging_dir.path())?;
218    Ok(staging_dir)
219}