spec_driven_docs/services/
upgrader.rs1use camino::{Utf8Path, Utf8PathBuf};
11
12use crate::adapters::fs::sha256_file;
13use crate::domain::manifest::{LegacyManifest, MANIFEST_PATH, Manifest, ManifestParseError};
14use crate::domain::ownership::Sha256;
15use crate::domain::profile::ProfileId;
16use crate::domain::version::CanonVersion;
17use crate::error::AppError;
18use crate::services::installer::{InitOptions, init, init_holding};
19
20#[derive(Debug, Clone)]
22pub struct UpgradeOptions {
23 pub target: Utf8PathBuf,
25 pub dry_run: bool,
27}
28
29#[derive(Debug, Default)]
31pub struct UpgradeOutcome {
32 pub lines: Vec<String>,
34 pub failures: usize,
36}
37
38struct Installed {
39 version: CanonVersion,
40 profile: ProfileId,
41 docs_root: String,
42 managed: Vec<(Utf8PathBuf, Sha256)>,
43 integration: Vec<(Utf8PathBuf, Sha256)>,
44 schema_current: bool,
51}
52
53fn markers_for(path: &str) -> (&'static str, &'static str) {
55 use crate::domain::marker::{AGENTS_BEGIN, AGENTS_END, BEGIN, END};
56 if path == crate::domain::paths::HOOKS_CONFIG_PATH {
57 (BEGIN, END)
58 } else {
59 (AGENTS_BEGIN, AGENTS_END)
60 }
61}
62
63fn read_installed(target: &Utf8Path) -> Result<Installed, AppError> {
64 let path = target.join(MANIFEST_PATH);
65 if !path.is_file() {
66 return Err(AppError::ManifestMissing(path));
67 }
68 let text = std::fs::read_to_string(&path)?;
69 match Manifest::parse(&text) {
70 Ok(manifest) => Ok(Installed {
71 version: manifest.canon_version,
72 profile: manifest.profile,
73 docs_root: manifest.docs_root.as_str().to_string(),
74 managed: manifest
75 .managed_files
76 .into_iter()
77 .map(|entry| (entry.destination, entry.sha256))
78 .collect(),
79 integration: manifest
80 .integration_blocks
81 .into_iter()
82 .map(|block| (block.path, block.marker_hash))
83 .collect(),
84 schema_current: true,
85 }),
86 Err(ManifestParseError::Older(_)) => {
87 let legacy: LegacyManifest = serde_json::from_str(&text)
88 .map_err(|e| AppError::ManifestInvalid(e.to_string()))?;
89 Ok(Installed {
90 version: legacy.canon_version,
91 profile: legacy.profile,
92 docs_root: legacy.docs_root.as_str().to_string(),
93 managed: legacy
94 .managed_files
95 .into_iter()
96 .map(|entry| (entry.destination, entry.sha256))
97 .collect(),
98 integration: legacy
99 .integration_blocks
100 .into_iter()
101 .map(|block| (block.path, block.marker_hash))
102 .collect(),
103 schema_current: false,
104 })
105 }
106 Err(error) => Err(AppError::ManifestInvalid(error.to_string())),
107 }
108}
109
110fn conflicts_at(target: &Utf8Path, installed: &Installed) -> Result<Vec<String>, AppError> {
132 let mut conflicts = Vec::new();
133 for (destination, recorded) in &installed.managed {
134 let file = target.join(destination);
135 if !file.is_file() {
136 conflicts.push(format!("CONFLICT missing managed file: {destination}"));
137 continue;
138 }
139 if sha256_file(&file)? != *recorded {
140 conflicts.push(format!(
141 "CONFLICT locally edited managed file: {destination}"
142 ));
143 }
144 }
145 for (path, recorded) in &installed.integration {
146 let full = target.join(path);
147 if !full.is_file() {
148 conflicts.push(format!("CONFLICT missing integration host: {path}"));
149 continue;
150 }
151 let (begin, end) = markers_for(path.as_str());
152 let host = std::fs::read_to_string(&full)?;
153 match crate::domain::marker::block_hash_with(&host, begin, end) {
154 Some(present) if present == *recorded => {}
155 _ => conflicts.push(format!("CONFLICT locally edited managed block: {path}")),
156 }
157 }
158 Ok(conflicts)
159}
160
161fn reinstall_options(target: &Utf8Path, profile: ProfileId) -> InitOptions {
167 InitOptions {
168 target: target.to_path_buf(),
169 profile,
170 apply: false,
171 dry_run: true,
172 docs_scratch: None,
173 reserve: Vec::new(),
174 writing_style: None,
175 }
176}
177
178fn report_removals(removed: &[String], outcome: &mut UpgradeOutcome) {
183 for raw in removed {
184 outcome
185 .lines
186 .push(format!("removed managed file no longer owned: {raw}"));
187 }
188}
189
190pub fn upgrade(options: &UpgradeOptions) -> Result<UpgradeOutcome, AppError> {
199 if !options.target.is_absolute() {
200 return Err(AppError::Usage("target must be absolute".to_string()));
201 }
202 if !options.target.is_dir() {
203 return Err(AppError::Usage(format!(
204 "unresolved target: {}",
205 options.target
206 )));
207 }
208 let target = Utf8PathBuf::from_path_buf(std::fs::canonicalize(&options.target)?)
209 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
210
211 let held = if options.dry_run {
216 None
217 } else {
218 Some(crate::landing::lock::hold(&target)?)
219 };
220
221 let installed = read_installed(&target)?;
222 let new = CanonVersion::current();
225 let old = installed.version;
226 let mut outcome = UpgradeOutcome::default();
227
228 if old > new {
229 return Err(AppError::Refused(format!(
230 "sdd {new} is older than the installed canon {old}; upgrade sdd"
231 )));
232 }
233
234 let conflicts = conflicts_at(&target, &installed)?;
239 if !conflicts.is_empty() {
240 let count = conflicts.len();
241 outcome.lines.extend(conflicts);
242 outcome.failures += count;
243 return Ok(outcome);
244 }
245
246 if old == new && installed.schema_current {
247 outcome.lines.push(format!("OK already at {new}"));
248 return Ok(outcome);
249 }
250
251 if options.dry_run {
252 if old == new {
255 outcome
256 .lines
257 .push(format!("DRY RUN migrate the record of {new}"));
258 } else {
259 outcome
260 .lines
261 .push(format!("DRY RUN upgrade {old} to {new}"));
262 }
263 let preview = init(
266 &reinstall_options(&target, installed.profile),
267 crate::landing::classify::Intent::Reconcile,
268 )
269 .map_err(|error| {
270 AppError::Refused(format!(
271 "upgrade could not be previewed from {old} to {new}: {error}"
272 ))
273 })?;
274 outcome.lines.extend(
275 preview
276 .lines
277 .into_iter()
278 .filter(|line| line.starts_with("note:")),
279 );
280 return Ok(outcome);
281 }
282
283 let reinstalled = init_holding(
284 held,
285 &InitOptions {
286 apply: true,
287 dry_run: false,
288 ..reinstall_options(&target, installed.profile)
289 },
290 crate::landing::classify::Intent::Reconcile,
293 )
294 .map_err(|error| {
295 AppError::Refused(format!(
296 "upgrade aborted during reinstall from {old} to {new}: {error}"
297 ))
298 })?;
299 let removed = reinstalled.removed.clone();
303 outcome.lines.extend(
304 reinstalled
305 .lines
306 .into_iter()
307 .filter(|line| line.starts_with("note:")),
308 );
309
310 finish(&target, &installed, &removed, old, new, &mut outcome);
311 Ok(outcome)
312}
313
314fn finish(
315 target: &Utf8Path,
316 installed: &Installed,
317 removed: &[String],
318 old: CanonVersion,
319 new: CanonVersion,
320 outcome: &mut UpgradeOutcome,
321) {
322 report_removals(removed, outcome);
323
324 let mut local_ids = std::collections::BTreeSet::new();
325 let specs = target.join(&installed.docs_root).join("specs");
326 if let Ok(entries) = specs.read_dir_utf8() {
327 for entry in entries.filter_map(Result::ok) {
328 if let Ok(text) = std::fs::read_to_string(entry.path()) {
329 local_ids.extend(crate::embedded::rule_ids_in(&text));
330 }
331 }
332 }
333 let upstream_only: Vec<String> = crate::embedded::spec_rule_ids()
334 .difference(&local_ids)
335 .cloned()
336 .collect();
337 if !upstream_only.is_empty() {
338 outcome
339 .lines
340 .push("upstream rule IDs not present locally:".to_string());
341 for id in upstream_only {
342 outcome.lines.push(format!(" {id}"));
343 }
344 }
345
346 if outcome.failures > 0 {
347 outcome.lines.push(format!(
348 "FAIL upgraded {old} to {new} with unfinished removals above"
349 ));
350 } else if old == new {
351 outcome
352 .lines
353 .push(format!("OK migrated the record of {new}"));
354 } else {
355 outcome.lines.push(format!("OK upgraded {old} to {new}"));
356 }
357}