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 docs_scratch: None,
180 reserve: Vec::new(),
181 writing_style: None,
182 }
183}
184
185fn report_removals(removed: &[String], outcome: &mut UpgradeOutcome) {
191 for raw in removed {
192 outcome
193 .lines
194 .push(format!("removed managed file no longer owned: {raw}"));
195 }
196}
197
198pub fn upgrade(
207 options: &UpgradeOptions,
208 bundle: &dyn crate::release::ReleaseBundle,
209) -> Result<UpgradeOutcome, AppError> {
210 if !options.target.is_absolute() {
211 return Err(AppError::Usage("target must be absolute".to_string()));
212 }
213 if !options.target.is_dir() {
214 return Err(AppError::Usage(format!(
215 "unresolved target: {}",
216 options.target
217 )));
218 }
219 let target = Utf8PathBuf::from_path_buf(std::fs::canonicalize(&options.target)?)
220 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
221
222 let installed = read_installed(&target)?;
223 let new: CanonVersion = bundle
227 .manifest()?
228 .version
229 .to_string()
230 .parse()
231 .map_err(|_| AppError::Refused("the release is not a version triple".to_string()))?;
232 let old = installed.version;
233 let mut outcome = UpgradeOutcome::default();
234
235 if old == new && installed.schema_current {
236 unanswerable(&options.selections)?;
237 outcome.lines.push(format!("OK already at {new}"));
238 return Ok(outcome);
239 }
240 if old > new {
241 return Err(AppError::Refused(format!(
242 "sdd {new} is older than the installed canon {old}; upgrade sdd"
243 )));
244 }
245
246 let conflicts = conflicts_at(&target, &installed)?;
247 if !conflicts.is_empty() {
248 if let Err(refused) = unanswerable(&options.selections) {
253 outcome.lines.push(format!("note: {refused}"));
254 }
255 let count = conflicts.len();
256 outcome.lines.extend(conflicts);
257 outcome.failures += count;
258 return Ok(outcome);
259 }
260
261 if options.dry_run {
262 if old == new {
265 outcome
266 .lines
267 .push(format!("DRY RUN migrate the record of {new}"));
268 } else {
269 outcome
270 .lines
271 .push(format!("DRY RUN upgrade {old} to {new}"));
272 }
273 let preview = init_with(
277 &options.selections,
278 &reinstall_options(&target, installed.profile),
279 bundle,
280 crate::plan::classify::Intent::Reconcile,
281 )
282 .map_err(|error| {
283 AppError::Refused(format!(
284 "upgrade could not be planned from {old} to {new}: {error}"
285 ))
286 })?;
287 outcome
288 .lines
289 .extend(preview.lines.into_iter().filter(|line| {
290 line.starts_with("BLOCKED")
291 || line.starts_with("DECISION")
292 || line.starts_with("note:")
293 }));
294 return Ok(outcome);
295 }
296
297 let reinstalled = init_with(
298 &options.selections,
299 &InitOptions {
300 apply: true,
301 dry_run: false,
302 ..reinstall_options(&target, installed.profile)
303 },
304 bundle,
305 crate::plan::classify::Intent::Reconcile,
308 )
309 .map_err(|error| {
310 AppError::Refused(format!(
311 "upgrade aborted during reinstall from {old} to {new}: {error}"
312 ))
313 })?;
314 let removed = reinstalled.removed.clone();
318 outcome.lines.extend(
319 reinstalled
320 .lines
321 .into_iter()
322 .filter(|line| line.starts_with("note:")),
323 );
324
325 finish(&target, &installed, &removed, old, new, &mut outcome);
326 Ok(outcome)
327}
328
329fn finish(
330 target: &Utf8Path,
331 installed: &Installed,
332 removed: &[String],
333 old: CanonVersion,
334 new: CanonVersion,
335 outcome: &mut UpgradeOutcome,
336) {
337 report_removals(removed, outcome);
338
339 let mut local_ids = std::collections::BTreeSet::new();
340 let specs = target.join(&installed.docs_root).join("specs");
341 if let Ok(entries) = specs.read_dir_utf8() {
342 for entry in entries.filter_map(Result::ok) {
343 if let Ok(text) = std::fs::read_to_string(entry.path()) {
344 local_ids.extend(crate::embedded::rule_ids_in(&text));
345 }
346 }
347 }
348 let upstream_only: Vec<String> = crate::embedded::spec_rule_ids()
349 .difference(&local_ids)
350 .cloned()
351 .collect();
352 if !upstream_only.is_empty() {
353 outcome
354 .lines
355 .push("upstream rule IDs not present locally:".to_string());
356 for id in upstream_only {
357 outcome.lines.push(format!(" {id}"));
358 }
359 }
360
361 if outcome.failures > 0 {
362 outcome.lines.push(format!(
363 "FAIL upgraded {old} to {new} with unfinished removals above"
364 ));
365 } else if old == new {
366 outcome
367 .lines
368 .push(format!("OK migrated the record of {new}"));
369 } else {
370 outcome.lines.push(format!("OK upgraded {old} to {new}"));
371 }
372}