Skip to main content

ito_core/implementation_readiness/
render_source.rs

1//! Immutable apply-input materialization from an authority commit.
2
3use std::path::{Component, Path, PathBuf};
4
5use tempfile::TempDir;
6
7use crate::change_meta::parse_change_meta;
8
9use super::ReadinessReport;
10use super::git::{GitTreeEntry, ReadinessGit};
11
12/// A temporary `.ito` tree populated exclusively from one captured authority
13/// commit.
14pub struct AuthoritativeChangeSource {
15    root: TempDir,
16    ito_path: PathBuf,
17    change_id: String,
18}
19
20impl AuthoritativeChangeSource {
21    /// Project root containing the materialized `.ito` directory.
22    #[must_use]
23    pub fn project_root(&self) -> &Path {
24        self.root.path()
25    }
26
27    /// Materialized authoritative `.ito` path.
28    #[must_use]
29    pub fn ito_path(&self) -> &Path {
30        &self.ito_path
31    }
32
33    /// Canonical change ID represented by this source.
34    #[must_use]
35    pub fn change_id(&self) -> &str {
36        &self.change_id
37    }
38}
39
40/// Failure to construct an immutable authoritative rendering source.
41#[derive(Debug, thiserror::Error)]
42#[error("{message}")]
43pub struct AuthoritativeSourceError {
44    path: Option<String>,
45    message: String,
46}
47
48impl AuthoritativeSourceError {
49    fn new(path: Option<String>, message: impl Into<String>) -> Self {
50        Self {
51            path,
52            message: message.into(),
53        }
54    }
55
56    /// Authority path associated with the failure, when known.
57    #[must_use]
58    pub fn path(&self) -> Option<&str> {
59        self.path.as_deref()
60    }
61}
62
63pub(super) fn materialize_authoritative_change(
64    prepare: &ReadinessReport,
65    repository_root: &Path,
66    guidance_artifacts: &[&str],
67    git: &dyn ReadinessGit,
68) -> Result<AuthoritativeChangeSource, AuthoritativeSourceError> {
69    if !prepare.ready {
70        return Err(AuthoritativeSourceError::new(
71            None,
72            "authoritative rendering requires a successful prepare report",
73        ));
74    }
75    let Some(snapshot) = prepare.authority_snapshot() else {
76        return Err(AuthoritativeSourceError::new(
77            None,
78            "authoritative rendering requires a captured authority commit",
79        ));
80    };
81    let change_id = prepare.change_id.as_str();
82    if !crate::templates::validate_change_name_input(change_id) {
83        return Err(AuthoritativeSourceError::new(
84            Some(change_id.to_string()),
85            "authoritative rendering requires a canonical safe change ID",
86        ));
87    }
88
89    let root = tempfile::tempdir().map_err(|error| {
90        AuthoritativeSourceError::new(None, format!("cannot create authority workspace: {error}"))
91    })?;
92    let ito_path = root.path().join(".ito");
93    let change_prefix = format!(".ito/changes/{change_id}");
94    let entries = materialize_prefix(
95        git,
96        repository_root,
97        &snapshot.oid,
98        &change_prefix,
99        root.path(),
100    )?;
101    let marker_path = format!("{change_prefix}/.ito.yaml");
102    let marker = entries
103        .iter()
104        .find(|entry| entry.path == marker_path && entry.is_regular_blob())
105        .ok_or_else(|| {
106            AuthoritativeSourceError::new(
107                Some(marker_path.clone()),
108                "authority commit does not contain a regular change metadata file",
109            )
110        })?;
111    let marker = git
112        .read_blob(repository_root, &marker.oid)
113        .map_err(|error| {
114            AuthoritativeSourceError::new(Some(marker_path.clone()), error.to_string())
115        })?;
116    let metadata = parse_change_meta(&marker)
117        .map_err(|error| AuthoritativeSourceError::new(Some(marker_path), error.to_string()))?;
118    let schema_name = metadata
119        .schema
120        .filter(|schema| !schema.trim().is_empty())
121        .ok_or_else(|| {
122            AuthoritativeSourceError::new(
123                Some(change_prefix.clone()),
124                "authoritative change metadata does not declare a schema",
125            )
126        })?;
127    if schema_name.contains('/') || schema_name.contains('\\') || schema_name.contains("..") {
128        return Err(AuthoritativeSourceError::new(
129            Some(change_prefix.clone()),
130            format!("authoritative change metadata declares unsafe schema '{schema_name}'"),
131        ));
132    }
133    let schema_prefix = format!(".ito/templates/schemas/{schema_name}");
134    materialize_prefix(
135        git,
136        repository_root,
137        &snapshot.oid,
138        &schema_prefix,
139        root.path(),
140    )?;
141
142    for artifact in guidance_artifacts {
143        if !safe_artifact_id(artifact) {
144            return Err(AuthoritativeSourceError::new(
145                None,
146                format!("unsafe guidance artifact ID '{artifact}'"),
147            ));
148        }
149        materialize_optional_file(
150            git,
151            repository_root,
152            &snapshot.oid,
153            &format!(".ito/user-prompts/{artifact}.md"),
154            root.path(),
155        )?;
156    }
157    materialize_optional_file(
158        git,
159        repository_root,
160        &snapshot.oid,
161        ".ito/user-prompts/guidance.md",
162        root.path(),
163    )?;
164    materialize_optional_file(
165        git,
166        repository_root,
167        &snapshot.oid,
168        ".ito/user-guidance.md",
169        root.path(),
170    )?;
171
172    Ok(AuthoritativeChangeSource {
173        root,
174        ito_path,
175        change_id: change_id.to_string(),
176    })
177}
178
179fn materialize_prefix(
180    git: &dyn ReadinessGit,
181    repository_root: &Path,
182    authority_oid: &str,
183    prefix: &str,
184    destination_root: &Path,
185) -> Result<Vec<GitTreeEntry>, AuthoritativeSourceError> {
186    let entries = git
187        .list_tree(repository_root, authority_oid, prefix)
188        .map_err(|error| {
189            AuthoritativeSourceError::new(Some(prefix.to_string()), error.to_string())
190        })?;
191    for entry in &entries {
192        materialize_entry(git, repository_root, entry, destination_root)?;
193    }
194    Ok(entries)
195}
196
197fn materialize_optional_file(
198    git: &dyn ReadinessGit,
199    repository_root: &Path,
200    authority_oid: &str,
201    path: &str,
202    destination_root: &Path,
203) -> Result<(), AuthoritativeSourceError> {
204    let entries = git
205        .list_tree(repository_root, authority_oid, path)
206        .map_err(|error| {
207            AuthoritativeSourceError::new(Some(path.to_string()), error.to_string())
208        })?;
209    for entry in &entries {
210        materialize_entry(git, repository_root, entry, destination_root)?;
211    }
212    Ok(())
213}
214
215fn materialize_entry(
216    git: &dyn ReadinessGit,
217    repository_root: &Path,
218    entry: &GitTreeEntry,
219    destination_root: &Path,
220) -> Result<(), AuthoritativeSourceError> {
221    if !entry.is_regular_blob() {
222        return Err(AuthoritativeSourceError::new(
223            Some(entry.path.clone()),
224            "authoritative rendering accepts regular Git blobs only",
225        ));
226    }
227    let relative = Path::new(&entry.path);
228    if relative.components().any(|component| match component {
229        Component::Normal(_) => false,
230        Component::Prefix(_) | Component::RootDir | Component::CurDir | Component::ParentDir => {
231            true
232        }
233    }) {
234        return Err(AuthoritativeSourceError::new(
235            Some(entry.path.clone()),
236            "authority tree contains an unsafe path",
237        ));
238    }
239    let destination = destination_root.join(relative);
240    let Some(parent) = destination.parent() else {
241        return Err(AuthoritativeSourceError::new(
242            Some(entry.path.clone()),
243            "authority path has no parent directory",
244        ));
245    };
246    std::fs::create_dir_all(parent).map_err(|error| {
247        AuthoritativeSourceError::new(
248            Some(entry.path.clone()),
249            format!("cannot create authority workspace path: {error}"),
250        )
251    })?;
252    let contents = git
253        .read_blob(repository_root, &entry.oid)
254        .map_err(|error| {
255            AuthoritativeSourceError::new(Some(entry.path.clone()), error.to_string())
256        })?;
257    std::fs::write(&destination, contents).map_err(|error| {
258        AuthoritativeSourceError::new(
259            Some(entry.path.clone()),
260            format!("cannot write authority workspace file: {error}"),
261        )
262    })
263}
264
265fn safe_artifact_id(artifact: &str) -> bool {
266    !artifact.is_empty()
267        && artifact.chars().all(|character| {
268            character.is_ascii_alphanumeric() || character == '-' || character == '_'
269        })
270}