ito_core/implementation_readiness/
render_source.rs1use 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
12pub struct AuthoritativeChangeSource {
15 root: TempDir,
16 ito_path: PathBuf,
17 change_id: String,
18}
19
20impl AuthoritativeChangeSource {
21 #[must_use]
23 pub fn project_root(&self) -> &Path {
24 self.root.path()
25 }
26
27 #[must_use]
29 pub fn ito_path(&self) -> &Path {
30 &self.ito_path
31 }
32
33 #[must_use]
35 pub fn change_id(&self) -> &str {
36 &self.change_id
37 }
38}
39
40#[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 #[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}