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_with};
19
20#[derive(Debug, Clone)]
22pub struct UpgradeOptions {
23 pub target: Utf8PathBuf,
25 pub dry_run: bool,
27 pub selections: crate::plan::decision::Selections,
29}
30
31#[derive(Debug, Default)]
33pub struct UpgradeOutcome {
34 pub lines: Vec<String>,
36 pub failures: usize,
38}
39
40struct Installed {
41 version: CanonVersion,
42 profile: ProfileId,
43 docs_root: String,
44 managed: Vec<(Utf8PathBuf, Sha256)>,
45 integration: Vec<(Utf8PathBuf, Sha256)>,
46 schema_current: bool,
53}
54
55fn markers_for(path: &str) -> (&'static str, &'static str) {
57 use crate::domain::marker::{AGENTS_BEGIN, AGENTS_END, BEGIN, END};
58 if path == crate::domain::paths::HOOKS_CONFIG_PATH {
59 (BEGIN, END)
60 } else {
61 (AGENTS_BEGIN, AGENTS_END)
62 }
63}
64
65fn read_installed(target: &Utf8Path) -> Result<Installed, AppError> {
66 let path = target.join(MANIFEST_PATH);
67 if !path.is_file() {
68 return Err(AppError::ManifestMissing(path));
69 }
70 let text = std::fs::read_to_string(&path)?;
71 match Manifest::parse(&text) {
72 Ok(manifest) => Ok(Installed {
73 version: manifest.canon_version,
74 profile: manifest.profile,
75 docs_root: manifest.docs_root.as_str().to_string(),
76 managed: manifest
77 .managed_files
78 .into_iter()
79 .map(|entry| (entry.destination, entry.sha256))
80 .collect(),
81 integration: manifest
82 .integration_blocks
83 .into_iter()
84 .map(|block| (block.path, block.marker_hash))
85 .collect(),
86 schema_current: true,
87 }),
88 Err(ManifestParseError::Older(_)) => {
89 let legacy: LegacyManifest = serde_json::from_str(&text)
90 .map_err(|e| AppError::ManifestInvalid(e.to_string()))?;
91 Ok(Installed {
92 version: legacy.canon_version,
93 profile: legacy.profile,
94 docs_root: legacy.docs_root.as_str().to_string(),
95 managed: legacy
96 .managed_files
97 .into_iter()
98 .map(|entry| (entry.destination, entry.sha256))
99 .collect(),
100 integration: legacy
101 .integration_blocks
102 .into_iter()
103 .map(|block| (block.path, block.marker_hash))
104 .collect(),
105 schema_current: false,
106 })
107 }
108 Err(error) => Err(AppError::ManifestInvalid(error.to_string())),
109 }
110}
111
112fn unanswerable(selections: &crate::plan::decision::Selections) -> Result<(), AppError> {
124 crate::plan::decision::validate(&[], selections)
125 .map_err(|error| AppError::Usage(error.to_string()))
126}
127
128fn conflicts_at(target: &Utf8Path, installed: &Installed) -> Result<Vec<String>, AppError> {
139 let mut conflicts = Vec::new();
140 for (destination, recorded) in &installed.managed {
141 let file = target.join(destination);
142 if !file.is_file() {
143 conflicts.push(format!("CONFLICT missing managed file: {destination}"));
144 continue;
145 }
146 if sha256_file(&file)? != *recorded {
147 conflicts.push(format!(
148 "CONFLICT locally edited managed file: {destination}"
149 ));
150 }
151 }
152 for (path, recorded) in &installed.integration {
153 let full = target.join(path);
154 if !full.is_file() {
155 conflicts.push(format!("CONFLICT missing integration host: {path}"));
156 continue;
157 }
158 let (begin, end) = markers_for(path.as_str());
159 let host = std::fs::read_to_string(&full)?;
160 match crate::domain::marker::block_hash_with(&host, begin, end) {
161 Some(present) if present == *recorded => {}
162 _ => conflicts.push(format!("CONFLICT locally edited managed block: {path}")),
163 }
164 }
165 Ok(conflicts)
166}
167
168fn reinstall_options(target: &Utf8Path, profile: ProfileId) -> InitOptions {
174 InitOptions {
175 target: target.to_path_buf(),
176 profile,
177 apply: false,
178 dry_run: true,
179 plan_zone: None,
180 docs_scratch: None,
181 reserve: Vec::new(),
182 writing_style: None,
183 }
184}
185
186fn report_removals(removed: &[String], outcome: &mut UpgradeOutcome) {
192 for raw in removed {
193 outcome
194 .lines
195 .push(format!("removed managed file no longer owned: {raw}"));
196 }
197}
198
199pub fn upgrade(
208 options: &UpgradeOptions,
209 bundle: &dyn crate::release::ReleaseBundle,
210) -> Result<UpgradeOutcome, AppError> {
211 if !options.target.is_absolute() {
212 return Err(AppError::Usage("target must be absolute".to_string()));
213 }
214 if !options.target.is_dir() {
215 return Err(AppError::Usage(format!(
216 "unresolved target: {}",
217 options.target
218 )));
219 }
220 let target = Utf8PathBuf::from_path_buf(std::fs::canonicalize(&options.target)?)
221 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
222
223 let installed = read_installed(&target)?;
224 let new: CanonVersion = bundle
228 .manifest()?
229 .version
230 .to_string()
231 .parse()
232 .map_err(|_| AppError::Refused("the release is not a version triple".to_string()))?;
233 let old = installed.version;
234 let mut outcome = UpgradeOutcome::default();
235
236 if old == new && installed.schema_current {
237 unanswerable(&options.selections)?;
238 outcome.lines.push(format!("OK already at {new}"));
239 return Ok(outcome);
240 }
241 if old > new {
242 return Err(AppError::Refused(format!(
243 "sdd {new} is older than the installed canon {old}; upgrade sdd"
244 )));
245 }
246
247 let conflicts = conflicts_at(&target, &installed)?;
248 if !conflicts.is_empty() {
249 if let Err(refused) = unanswerable(&options.selections) {
254 outcome.lines.push(format!("note: {refused}"));
255 }
256 let count = conflicts.len();
257 outcome.lines.extend(conflicts);
258 outcome.failures += count;
259 return Ok(outcome);
260 }
261
262 if options.dry_run {
263 if old == new {
266 outcome
267 .lines
268 .push(format!("DRY RUN migrate the record of {new}"));
269 } else {
270 outcome
271 .lines
272 .push(format!("DRY RUN upgrade {old} to {new}"));
273 }
274 let preview = init_with(
278 &options.selections,
279 &reinstall_options(&target, installed.profile),
280 bundle,
281 crate::plan::classify::Intent::Reconcile,
282 )
283 .map_err(|error| {
284 AppError::Refused(format!(
285 "upgrade could not be planned from {old} to {new}: {error}"
286 ))
287 })?;
288 outcome
289 .lines
290 .extend(preview.lines.into_iter().filter(|line| {
291 line.starts_with("BLOCKED")
292 || line.starts_with("DECISION")
293 || line.starts_with("note:")
294 }));
295 return Ok(outcome);
296 }
297
298 let reinstalled = init_with(
299 &options.selections,
300 &InitOptions {
301 apply: true,
302 dry_run: false,
303 ..reinstall_options(&target, installed.profile)
304 },
305 bundle,
306 crate::plan::classify::Intent::Reconcile,
309 )
310 .map_err(|error| {
311 AppError::Refused(format!(
312 "upgrade aborted during reinstall from {old} to {new}: {error}"
313 ))
314 })?;
315 let removed = reinstalled.removed.clone();
319 outcome.lines.extend(
320 reinstalled
321 .lines
322 .into_iter()
323 .filter(|line| line.starts_with("note:")),
324 );
325
326 finish(&target, &installed, &removed, old, new, &mut outcome);
327 Ok(outcome)
328}
329
330fn finish(
331 target: &Utf8Path,
332 installed: &Installed,
333 removed: &[String],
334 old: CanonVersion,
335 new: CanonVersion,
336 outcome: &mut UpgradeOutcome,
337) {
338 report_removals(removed, outcome);
339
340 let mut local_ids = std::collections::BTreeSet::new();
341 let specs = target.join(&installed.docs_root).join("specs");
342 if let Ok(entries) = specs.read_dir_utf8() {
343 for entry in entries.filter_map(Result::ok) {
344 if let Ok(text) = std::fs::read_to_string(entry.path()) {
345 local_ids.extend(crate::embedded::rule_ids_in(&text));
346 }
347 }
348 }
349 let upstream_only: Vec<String> = crate::embedded::spec_rule_ids()
350 .difference(&local_ids)
351 .cloned()
352 .collect();
353 if !upstream_only.is_empty() {
354 outcome
355 .lines
356 .push("upstream rule IDs not present locally:".to_string());
357 for id in upstream_only {
358 outcome.lines.push(format!(" {id}"));
359 }
360 }
361
362 if outcome.failures > 0 {
363 outcome.lines.push(format!(
364 "FAIL upgraded {old} to {new} with unfinished removals above"
365 ));
366 } else if old == new {
367 outcome
368 .lines
369 .push(format!("OK migrated the record of {new}"));
370 } else {
371 outcome.lines.push(format!("OK upgraded {old} to {new}"));
372 }
373}