1use std::{ffi::OsString, path::Path};
2
3use anyhow::{bail, Context, Result};
4use serde::Deserialize;
5
6use crate::{
7 adapter::adapter_for_plan,
8 configuration,
9 crew::{self, CrewEvidence},
10 embedding, emission,
11 model::{ModelArtifactState, Plan, ShareState, State, ToolState},
12};
13
14#[derive(Deserialize)]
15struct ImportPromotion {
16 eligible: bool,
17 blockers: Vec<String>,
18}
19
20#[derive(Deserialize)]
21struct ImportResult {
22 outcome: String,
23 share_id: String,
24 staging_graph: String,
25 promotion: ImportPromotion,
26}
27
28#[derive(Debug, PartialEq, Eq)]
36pub(crate) enum Convergence {
37 Current,
39 Converge,
41 Decline,
45 RefuseSkipInstall,
49}
50
51pub(crate) fn decide_convergence(
52 installed: &str,
53 desired: &str,
54 is_current: bool,
55 skip_install: bool,
56) -> Convergence {
57 if is_current {
58 return Convergence::Current;
59 }
60 if skip_install {
61 return Convergence::RefuseSkipInstall;
62 }
63 match crate::release_update::behind_reviewed(installed, desired) {
64 Some(true) => Convergence::Converge,
65 Some(false) | None => Convergence::Decline,
68 }
69}
70
71pub fn apply(plan: &Plan, state_path: &Path, skip_install: bool) -> Result<State> {
72 plan.validate()?;
73 let mut state = State::read(state_path)?;
74 let mut failures = Vec::new();
75 for &name in &plan.tools {
76 if name == crate::model::ToolName::Quipu && plan.external_quipu {
77 state.tools.remove(name.as_str());
79 state.write(state_path)?;
80 }
81 let adapter = adapter_for_plan(name, plan)?;
82 let desired = adapter.desired_version();
83 let version = (|| -> Result<String> {
94 Ok(match adapter.version() {
95 Ok(installed) if adapter.is_current(&installed) => installed,
96
97 Ok(installed) => {
101 match decide_convergence(&installed, &desired, false, skip_install) {
102 Convergence::Current => installed,
103 Convergence::RefuseSkipInstall => bail!(
104 "{} is installed at {installed} but this Caboodle build reviewed {desired}; \
105 --skip-install refuses to converge it. Install {desired}, or drop \
106 --skip-install to let apply converge it.",
107 name.as_str()
108 ),
109 Convergence::Decline => {
110 println!(
111 "{}: NOT converged — installed {installed}, reviewed {desired}. \
112 Apply does not downgrade or replace an unrecognised version; \
113 use `caboodle update-release` deliberately if that is what you want.",
114 name.as_str()
115 );
116 installed
117 }
118 Convergence::Converge => {
119 eprintln!(
120 "{}: stale ({installed}); converging to reviewed {desired}",
121 name.as_str()
122 );
123 adapter.install().with_context(|| {
124 format!("{} convergence to reviewed {desired}", name.as_str())
125 })?;
126 let after = adapter.version().with_context(|| {
127 format!("{} version read-back after convergence", name.as_str())
128 })?;
129 if !adapter.is_current(&after) {
131 bail!(
132 "{} did not reach reviewed release {desired} (got {after}); \
133 refusing to record it as applied",
134 name.as_str()
135 );
136 }
137 println!("{}: converged {installed} -> {after}", name.as_str());
138 after
139 }
140 }
141 }
142
143 Err(error)
144 if !skip_install
145 && !(name == crate::model::ToolName::Quipu && plan.external_quipu) =>
146 {
147 eprintln!("{}: not installed ({error:#}); installing", name.as_str());
148 adapter
149 .install()
150 .with_context(|| {
151 format!(
152 "{} install step (run `caboodle doctor` to list every blocker on this host)",
153 name.as_str()
154 )
155 })?;
156 adapter.version().with_context(|| {
157 format!("{} version read-back after install", name.as_str())
158 })?
159 }
160 Err(error) => {
161 return Err(error).context(format!("{} version read-back", name.as_str()))
162 }
163 })
164 })();
165 let version = version.and_then(|version| {
170 if name == crate::model::ToolName::Quipu && !plan.external_quipu {
171 if skip_install {
174 crate::adapter::verify_existing_quipu_alias()?;
175 } else {
176 crate::adapter::ensure_existing_quipu_alias()?;
177 }
178 }
179 if !(name == crate::model::ToolName::Quipu && plan.external_quipu) {
180 check_path_resolution(name)?;
181 }
182 Ok(version)
183 });
184 let version = match version {
185 Ok(version) => version,
186 Err(error) => {
187 state.tools.remove(name.as_str());
190 state.write(state_path)?;
191 eprintln!("{}: FAILED: {error:#}", name.as_str());
192 failures.push(format!("{}: {error:#}", name.as_str()));
193 continue;
194 }
195 };
196 let remains_verified = state
197 .tools
198 .get(name.as_str())
199 .is_some_and(|previous| previous.version == version && previous.verified);
200 state.tools.insert(
201 name.as_str().to_owned(),
202 ToolState {
203 version,
204 applied: true,
205 verified: remains_verified,
206 },
207 );
208 state.write(state_path)?;
209 emission::queue_transition(
210 state_path,
211 name.as_str(),
212 "applied",
213 &state.tools[name.as_str()].version,
214 )?;
215 println!("{}: applied", name.as_str());
216 }
217 if !failures.is_empty() {
218 bail!(
219 "{} tool(s) failed to apply:\n{}\nSuccessful tools were saved. Fix the failures and rerun the same command. \
220 Stack configuration, model provisioning, crew setup, share imports and verification \
221 have not run; run `caboodle doctor` for preflight guidance.",
222 failures.len(),
223 failures.join("\n")
224 );
225 }
226 if let Some(config) = &plan.stack_config {
227 configuration::apply(config)?;
228 println!("stack configuration: applied");
229 }
230 if let Some(model) = &plan.embedding_model {
231 let provisioned = embedding::provision(model, crate::adapter::download_https)
232 .context("embedding-model provisioning step")?;
233 for artifact in provisioned {
234 let remains_verified = state
237 .models
238 .get(&artifact.name)
239 .is_some_and(|previous| previous.sha256 == artifact.sha256 && previous.verified);
240 state.models.insert(
241 artifact.name.clone(),
242 ModelArtifactState {
243 path: artifact.path.clone(),
244 sha256: artifact.sha256.clone(),
245 provisioned: true,
246 verified: remains_verified,
247 },
248 );
249 state.write(state_path)?;
250 emission::queue_transition(
251 state_path,
252 &format!("embedding-model/{}", artifact.name),
253 "applied",
254 &artifact.sha256,
255 )?;
256 let outcome = match artifact.outcome {
257 embedding::ArtifactOutcome::Fetched => "provisioned",
258 embedding::ArtifactOutcome::Current => "current",
259 };
260 println!("embedding-model {}: {outcome}", artifact.name);
261 }
262 }
263 #[cfg(unix)]
264 if let Some(mcp) = &plan.quipu_mcp {
265 let outcome = crate::quipu_mcp::provision(&mcp.url, mcp.token_file.as_deref())
266 .context("quipu MCP registration step (headersHelper)")?;
267 println!("quipu mcp: {outcome}");
268 }
269 if let Some(selection) = &plan.crew {
270 crew::apply(selection, &mut state, skip_install)?;
271 state.write(state_path)?;
272 for (name, runtime) in &state.crew {
273 emission::queue_transition(state_path, name, "applied", &runtime.version)?;
274 }
275 }
276 let resolved = crate::tool_bundles::resolve(&crate::hook_bundles::selected(
280 plan.tools.iter().map(|t| t.as_str()),
281 ));
282 for (name, _, source) in &resolved {
283 println!("hook bundle {name}: source {}", source.label());
284 }
285 for line in crate::hook_bundles::register(&crate::tool_bundles::as_pairs(&resolved), "st")? {
286 println!("hook bundle {line}");
287 }
288 if crate::dp_alias::wanted(plan.tools.iter().map(|t| t.as_str())) {
291 let outcome = crate::dp_alias::apply(crate::dp_alias::locate().as_deref(), None)
292 .context("Desire Path bd -> sd alias step")?;
293 println!("dp alias {}", outcome.line());
294 }
295 load_vocabulary(plan)?;
296 consume_shares(plan, &mut state, state_path)?;
297 Ok(state)
298}
299
300fn load_vocabulary(plan: &Plan) -> Result<()> {
305 if !plan.tools.contains(&crate::model::ToolName::Quipu) {
306 return Ok(());
307 }
308 let cached = crate::vocabulary::ensure_cached()?;
309 match plan.quipu_db.as_deref() {
310 Some(db) => {
311 crate::vocabulary::load(db)?;
312 println!(
313 "vocabulary: quechua v{} loaded into {}",
314 crate::vocabulary::QUECHUA_VERSION,
315 db.display()
316 );
317 }
318 None => println!(
319 "vocabulary: quechua v{} cached at {} (no plan quipu_db to load into)",
320 crate::vocabulary::QUECHUA_VERSION,
321 cached.display()
322 ),
323 }
324 Ok(())
325}
326
327fn consume_shares(plan: &Plan, state: &mut State, state_path: &Path) -> Result<()> {
328 let Some(db) = plan.quipu_db.as_deref() else {
329 return Ok(());
330 };
331 for share in &plan.shares {
332 let result = crate::adapter::checked(
333 "quipu",
334 [
335 OsString::from("import"),
336 share.as_os_str().to_owned(),
337 OsString::from("--db"),
338 db.as_os_str().to_owned(),
339 ],
340 None,
341 )
342 .with_context(|| format!("import canonical Quipu share {}", share.display()))?;
343 let imported: ImportResult = serde_json::from_slice(&result.stdout)
344 .with_context(|| format!("parse Quipu import result for {}", share.display()))?;
345 if !matches!(
346 imported.outcome.as_str(),
347 "staged" | "quarantined" | "unchanged"
348 ) {
349 bail!(
350 "Quipu returned unknown share import outcome {:?} for {}",
351 imported.outcome,
352 share.display()
353 );
354 }
355 state.shares.insert(
356 imported.share_id.clone(),
357 ShareState {
358 path: share.clone(),
359 staging_graph: imported.staging_graph,
360 outcome: imported.outcome.clone(),
361 promotion_eligible: imported.promotion.eligible,
362 blockers: imported.promotion.blockers,
363 },
364 );
365 state.write(state_path)?;
366 println!(
367 "share {}: {} (promotion eligible: {})",
368 imported.share_id, imported.outcome, imported.promotion.eligible
369 );
370 }
371 Ok(())
372}
373
374fn check_path_resolution(name: crate::model::ToolName) -> Result<()> {
378 let path = std::env::var_os("PATH");
379 for &program in crate::adapter::programs(name) {
380 match crate::adapter::path_resolution(program, path.as_deref()) {
381 crate::adapter::PathResolution::Managed => {}
382 crate::adapter::PathResolution::NotOnPath => println!(
383 "{}: note: `{program}` is not on PATH; add the install directory to PATH to run it",
384 name.as_str()
385 ),
386 crate::adapter::PathResolution::SameBuild { runs, managed } => println!(
387 "{}: note: PATH runs {} rather than {}, but both report the same version",
388 name.as_str(),
389 runs.display(),
390 managed.display()
391 ),
392 crate::adapter::PathResolution::Shadowed {
393 runs,
394 runs_version,
395 managed,
396 managed_version,
397 } => anyhow::bail!(
398 "{program} is SHADOWED: PATH runs {} ({}), not the verified {} ({}). \
399 Remove the stale copy or put {} earlier on PATH",
400 runs.display(),
401 runs_version.trim(),
402 managed.display(),
403 managed_version.trim(),
404 managed
405 .parent()
406 .map(|d| d.display().to_string())
407 .unwrap_or_default()
408 ),
409 }
410 }
411 Ok(())
412}
413
414pub fn verify(plan: &Plan, state_path: &Path, evidence: &CrewEvidence) -> Result<State> {
415 plan.validate()?;
416 let mut state = State::read(state_path)?;
417 for line in hook_bundles_verify(plan)? {
423 println!("hook bundle {line}");
424 }
425 if plan.tools.contains(&crate::model::ToolName::Quipu) {
426 if let Some(record) = state.tools.get_mut("quipu") {
427 record.verified = false;
428 state.write(state_path)?;
429 }
430 let server = std::env::var("QUIPU_SERVER").unwrap_or_else(|_| {
431 plan.quipu_mcp
432 .as_ref()
433 .map(|mcp| mcp.url.clone())
434 .unwrap_or_else(|| "http://localhost:3030".to_owned())
435 });
436 let source = crate::quipu_credential::verify(&server)
437 .context("live Quipu client credential verification")?;
438 println!("quipu credential: canonical file 0400/0700 and authenticated read verified; source: {source}");
439 }
440 for &name in &plan.tools {
441 if name == crate::model::ToolName::Quipu && plan.external_quipu {
442 state.tools.remove(name.as_str());
444 state.write(state_path)?;
445 }
446 let adapter = adapter_for_plan(name, plan)?;
447 let version = adapter
448 .version()
449 .with_context(|| format!("{} version read-back", name.as_str()))?;
450 adapter.verify().with_context(|| {
457 let desired = adapter.desired_version();
458 if adapter.is_current(&version) {
459 format!("{} functional verification", name.as_str())
460 } else {
461 format!(
462 "{} functional verification — VERSION SKEW: {version} is installed, \
463 this Caboodle build reviewed and verifies against {desired}. The error \
464 below is most likely that skew, not a broken tool. Converge with \
465 `caboodle apply` (or `caboodle update`), then re-verify.",
466 name.as_str()
467 )
468 }
469 })?;
470 if !(name == crate::model::ToolName::Quipu && plan.external_quipu) {
471 check_path_resolution(name)?;
472 }
473 state.tools.insert(
474 name.as_str().to_owned(),
475 ToolState {
476 version,
477 applied: true,
478 verified: true,
479 },
480 );
481 state.write(state_path)?;
482 emission::queue_transition(
483 state_path,
484 name.as_str(),
485 "verified",
486 &state.tools[name.as_str()].version,
487 )?;
488 println!("{}: verified", name.as_str());
489 }
490 if crate::dp_alias::wanted(plan.tools.iter().map(|t| t.as_str())) {
492 let outcome = crate::dp_alias::verify(crate::dp_alias::locate().as_deref(), None)
493 .context("Desire Path bd -> sd alias")?;
494 println!("dp alias {}", outcome.line());
495 }
496 if let Some(model) = &plan.embedding_model {
497 embedding::verify(model).context("embedding-model artifact re-hash step")?;
500 for artifact in &model.artifacts {
501 state.models.insert(
502 artifact.name.clone(),
503 ModelArtifactState {
504 path: model.destination.join(&artifact.name),
505 sha256: artifact.sha256.clone(),
506 provisioned: true,
507 verified: true,
508 },
509 );
510 state.write(state_path)?;
511 emission::queue_transition(
512 state_path,
513 &format!("embedding-model/{}", artifact.name),
514 "verified",
515 &artifact.sha256,
516 )?;
517 println!("embedding-model {}: digest re-checked", artifact.name);
518 }
519 }
520 #[cfg(unix)]
521 if let Some(mcp) = &plan.quipu_mcp {
522 let notes = crate::quipu_mcp::verify(&mcp.url)
523 .context("quipu MCP authenticated-write probe (headersHelper)")?;
524 for note in notes {
525 println!("quipu mcp: {note}");
526 }
527 println!("quipu mcp: authenticated MCP write reached Quipu's parser; unauthenticated control was refused");
528 }
529 if let Some(selection) = &plan.crew {
530 crew::verify(selection, evidence, &mut state)?;
531 state.write(state_path)?;
532 for (name, runtime) in &state.crew {
533 emission::queue_transition(state_path, name, "verified", &runtime.version)?;
534 }
535 }
536 Ok(state)
537}
538
539pub(crate) fn reviewed_drift_status(installed: &str, desired: &str) -> &'static str {
542 match crate::release_update::behind_reviewed(installed, desired) {
543 Some(true) => "update available",
544 Some(false) if crate::release_update::behind_reviewed(desired, installed) == Some(true) => {
545 "ahead of reviewed pin; downgrade refused"
546 }
547 Some(false) | None => "version drift; review required",
548 }
549}
550
551fn package_owned_status(name: crate::model::ToolName, desired: &str) -> Option<(String, bool)> {
555 use crate::package::{apt_receipt, primary_program, standing, Standing};
556 let receipt = primary_program(name)
557 .and_then(|program| apt_receipt(&program))
558 .or_else(|| crate::package::brew_receipt(name))?;
559 let status = standing(&receipt.version, desired);
560 let verdict = match status {
561 Standing::Current => "current",
562 Standing::Ahead => "ahead of reviewed pin; downgrade refused",
563 Standing::Behind => {
564 "behind reviewed pin; delivered by the package lane, not installed by caboodle"
565 }
566 Standing::Unknown => "version drift; review required",
567 };
568 Some((
569 format!(
570 "{}: {verdict} (package-owned: {} {} {}; reviewed: {desired})",
571 name.as_str(),
572 receipt.manager,
573 receipt.package,
574 receipt.version
575 ),
576 status == Standing::Current,
577 ))
578}
579
580fn brew_update(
586 name: crate::model::ToolName,
587 adapter: &dyn crate::adapter::Adapter,
588) -> Result<Option<String>> {
589 use crate::package::{
590 brew_available, brew_converge, brew_formula, brew_program, brew_receipt, standing, Standing,
591 };
592 if brew_formula(name).is_none() || brew_program().is_none() {
593 return Ok(None);
594 }
595 let desired = adapter.desired_version();
596 match brew_receipt(name) {
597 Some(receipt) => match standing(&receipt.version, &desired) {
598 Standing::Behind => brew_converge(name, true)?,
599 verdict => {
600 let word = match verdict {
601 Standing::Current => "current",
602 Standing::Ahead => "ahead of reviewed pin; downgrade refused",
603 _ => "version drift; review required",
604 };
605 println!(
606 "{}: {word} (package-owned: brew {} {}; reviewed: {desired})",
607 name.as_str(),
608 receipt.package,
609 receipt.version
610 );
611 return Ok(Some(adapter.version()?));
612 }
613 },
614 None => {
615 let Some(available) = brew_available(name) else {
616 return Ok(None);
617 };
618 if standing(&available, &desired) == Standing::Behind {
620 return Ok(None);
621 }
622 if let Ok(installed) = adapter.version() {
624 if crate::release_update::behind_reviewed(&format!("pkg {available}"), &installed)
625 == Some(true)
626 {
627 println!(
628 "{}: installed {installed} is newer than {} {available}; not installed by brew",
629 name.as_str(),
630 crate::package::STACK_TAP
631 );
632 return Ok(Some(installed));
633 }
634 }
635 brew_converge(name, false)?;
636 }
637 }
638 let receipt = brew_receipt(name).context("brew receipt after install")?;
639 adapter
640 .verify()
641 .with_context(|| format!("{} verification after brew delivery", name.as_str()))?;
642 let version = adapter
643 .version()
644 .with_context(|| format!("{} version read-back after brew delivery", name.as_str()))?;
645 println!(
646 "{}: delivered by brew ({} {}) and verified",
647 name.as_str(),
648 receipt.package,
649 receipt.version
650 );
651 Ok(Some(version))
652}
653
654pub fn check_updates(plan: &Plan) -> Result<bool> {
655 plan.validate()?;
656 let mut current = true;
657 for &name in &plan.tools {
658 let adapter = adapter_for_plan(name, plan)?;
659 let desired = adapter.desired_version();
660 if name == crate::model::ToolName::Quipu && plan.external_quipu {
661 println!(
662 "quipu: externally owned; release convergence not assessed ({})",
663 adapter.version()?
664 );
665 continue;
666 }
667 if let Some((line, is_current)) = package_owned_status(name, &desired) {
668 current &= is_current;
669 println!("{line}");
670 continue;
671 }
672 match adapter.version() {
673 Ok(installed) if adapter.is_current(&installed) => {
674 println!("{}: current ({installed})", name.as_str());
675 }
676 Ok(installed) => {
677 current = false;
678 println!(
679 "{}: {} (installed: {installed}; reviewed: {desired})",
680 name.as_str(),
681 reviewed_drift_status(&installed, &desired)
682 );
683 }
684 Err(error) => {
685 current = false;
686 println!(
687 "{}: missing or unreadable ({error:#}); reviewed: {desired}",
688 name.as_str()
689 );
690 }
691 }
692 }
693 if let Some(selection) = &plan.crew {
694 current &= crew::check_updates(selection);
695 }
696 Ok(current)
697}
698
699pub fn update(plan: &Plan, state_path: &Path, evidence: &CrewEvidence) -> Result<State> {
702 plan.validate()?;
703 let mut state = State::read(state_path)?;
704 for &name in &plan.tools {
705 let adapter = adapter_for_plan(name, plan)?;
706 if name == crate::model::ToolName::Quipu && plan.external_quipu {
707 println!(
708 "quipu: externally owned; update skipped ({})",
709 adapter.version()?
710 );
711 continue;
712 }
713 if let Some(version) = brew_update(name, adapter.as_ref())? {
714 state.tools.insert(
715 name.as_str().to_owned(),
716 ToolState {
717 version: version.clone(),
718 applied: true,
719 verified: true,
720 },
721 );
722 state.write(state_path)?;
723 emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
724 continue;
725 }
726 if let Some((line, _)) = package_owned_status(name, &adapter.desired_version()) {
727 println!("{line}");
728 continue;
729 }
730 let before = adapter.version().ok();
731 if before
732 .as_deref()
733 .is_some_and(|installed| adapter.is_current(installed))
734 {
735 if name == crate::model::ToolName::Quipu {
736 crate::adapter::ensure_existing_quipu_alias()?;
737 }
738 println!("{}: current", name.as_str());
739 continue;
740 }
741 #[cfg(unix)]
742 crate::release_update::guard_reviewed_update(name, &adapter.desired_version())?;
743 eprintln!(
744 "{}: converging {:?} -> {}",
745 name.as_str(),
746 before,
747 adapter.desired_version()
748 );
749 adapter
750 .install()
751 .with_context(|| format!("{} reviewed update install", name.as_str()))?;
752 let version = adapter
753 .version()
754 .with_context(|| format!("{} version read-back after update", name.as_str()))?;
755 if !adapter.is_current(&version) {
756 bail!(
757 "{} update did not reach reviewed release {} (got {})",
758 name.as_str(),
759 adapter.desired_version(),
760 version
761 );
762 }
763 adapter
764 .verify()
765 .with_context(|| format!("{} verification after update", name.as_str()))?;
766 state.tools.insert(
767 name.as_str().to_owned(),
768 ToolState {
769 version: version.clone(),
770 applied: true,
771 verified: true,
772 },
773 );
774 state.write(state_path)?;
775 emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
776 println!("{}: updated and verified", name.as_str());
777 }
778 if let Some(selection) = &plan.crew {
779 if !crew::check_updates(selection) {
780 crew::apply(selection, &mut state, false)?;
781 crew::verify(selection, evidence, &mut state)?;
782 for line in hook_bundles_verify(plan)? {
783 println!("hook bundle {line}");
784 }
785 state.write(state_path)?;
786 for (name, runtime) in &state.crew {
787 emission::queue_transition(state_path, name, "updated", &runtime.version)?;
788 }
789 }
790 }
791 Ok(state)
792}
793
794fn hook_bundles_verify(plan: &Plan) -> Result<Vec<String>> {
795 let resolved = crate::tool_bundles::resolve(&crate::hook_bundles::selected(
796 plan.tools.iter().map(|t| t.as_str()),
797 ));
798 crate::hook_bundles::verify(&crate::tool_bundles::as_pairs(&resolved))
799}
800
801pub fn verify_questions(plan: &Plan, db: Option<&Path>) -> Result<()> {
802 plan.validate()?;
803 let intent = plan.intent.as_ref().context(
804 "plan has no intended-use/question contract; regenerate it through the Phase 2 interview",
805 )?;
806 for (index, contract) in intent.anticipated_questions.iter().enumerate() {
807 let scratch = if contract.is_self_test() {
810 Some(seed_self_test_store()?)
811 } else {
812 None
813 };
814 let db = scratch.as_ref().map(|(_, db)| db.as_path()).or(db);
815 let mut args = vec![OsString::from("read"), OsString::from(&contract.sparql)];
816 if let Some(db) = db {
817 args.push(OsString::from("--db"));
818 args.push(db.as_os_str().to_owned());
819 }
820 let result = crate::adapter::checked("quipu", args, None)
821 .with_context(|| format!("anticipated question {} query", index + 1))?;
822 let answer = String::from_utf8_lossy(&result.stdout);
823 if !answer.contains(&contract.expected) {
824 anyhow::bail!(
825 "anticipated question {} was executable but its answer did not contain {:?}: {}",
826 index + 1,
827 contract.expected,
828 contract.question
829 );
830 }
831 println!("question {}: verified — {}", index + 1, contract.question);
832 }
833 Ok(())
834}
835
836fn seed_self_test_store() -> Result<(tempfile::TempDir, std::path::PathBuf)> {
839 use crate::model::SELF_TEST_MARKER;
840 let dir = tempfile::tempdir().context("create self-test question store")?;
841 let db = dir.path().join("self-test.db");
842 let episode = dir.path().join("episode.json");
843 std::fs::write(
844 &episode,
845 format!(
846 r#"{{"name":"caboodle self-test question","episode_body":"self-test seed","source":"caboodle","group_id":"caboodle-verification","nodes":[{{"name":"{SELF_TEST_MARKER}","type":"Verification","description":"caboodle self-test question seed"}}],"edges":[]}}"#
847 ),
848 )
849 .context("write self-test episode")?;
850 crate::adapter::checked(
851 "quipu",
852 [
853 OsString::from("episode"),
854 episode.into_os_string(),
855 OsString::from("--db"),
856 db.clone().into_os_string(),
857 ],
858 None,
859 )
860 .context("seed the self-test question store")?;
861 Ok((dir, db))
862}
863
864pub fn provision_quipu_credential(source: &Path, server: &str) -> Result<()> {
866 let home = std::env::var_os("HOME").context("HOME required for canonical Quipu credential")?;
867 crate::quipu_credential::provision(source, server, Path::new(&home))?;
868 println!(
869 "quipu credential: issued file accepted and canonical installation verified (0400/0700)"
870 );
871 Ok(())
872}
873
874#[cfg(test)]
875mod convergence_tests {
876 use super::{decide_convergence, reviewed_drift_status, Convergence};
877
878 const REVIEWED: &str = "bobbin 0.16.2";
879
880 #[test]
881 fn reviewed_drift_never_proposes_a_downgrade_or_unknown_replacement() {
882 assert_eq!(
883 reviewed_drift_status("sd 0.1.4 (seeds)", "seeds 0.0.4"),
884 "ahead of reviewed pin; downgrade refused"
885 );
886 assert_eq!(
887 reviewed_drift_status("sd 0.0.3", "seeds 0.0.4"),
888 "update available"
889 );
890 for installed in ["sd dev", "sd 0.0.4", "sd 0.0.4-rc1"] {
891 assert_eq!(
892 reviewed_drift_status(installed, "seeds 0.0.4"),
893 "version drift; review required"
894 );
895 }
896 }
897
898 #[test]
899 fn current_is_left_alone_even_under_skip_install() {
900 assert_eq!(
903 decide_convergence(REVIEWED, REVIEWED, true, true),
904 Convergence::Current
905 );
906 assert_eq!(
907 decide_convergence(REVIEWED, REVIEWED, true, false),
908 Convergence::Current
909 );
910 }
911
912 #[test]
913 fn stale_converges_and_skip_install_refuses_instead_of_claiming_applied() {
914 assert_eq!(
915 decide_convergence("bobbin 0.1.0", REVIEWED, false, false),
916 Convergence::Converge
917 );
918 assert_eq!(
919 decide_convergence("bobbin 0.1.0", REVIEWED, false, true),
920 Convergence::RefuseSkipInstall
921 );
922 }
923
924 #[test]
925 fn ahead_or_incomparable_is_declined_not_replaced() {
926 assert_eq!(
927 decide_convergence("bobbin 0.99.0", REVIEWED, false, false),
928 Convergence::Decline
929 );
930 assert_eq!(
931 decide_convergence("bobbin dev", REVIEWED, false, false),
932 Convergence::Decline
933 );
934 }
935}