Skip to main content

canic_host/
adoption.rs

1//! Passive adoption profile and onboarding reports.
2
3use crate::deployment_truth::{
4    ArtifactSourceV1, CanisterControlClassV1, DeploymentInventoryV1, RoleArtifactManifestV1,
5};
6use canic_core::{bootstrap::parse_config_model, ids::CanisterRole};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use thiserror::Error;
10
11pub const ADOPTION_REPORT_SCHEMA_VERSION: u32 = 1;
12
13///
14/// AdoptionReportRequest
15///
16#[derive(Clone, Debug)]
17pub struct AdoptionReportRequest<'a> {
18    pub report_id: &'a str,
19    pub generated_at: &'a str,
20    pub profile: AdoptionProfileV1,
21    pub config_source: &'a str,
22    pub inventory: Option<&'a DeploymentInventoryV1>,
23    pub artifact_manifest: Option<&'a RoleArtifactManifestV1>,
24    pub package_metadata: Vec<AdoptionPackageMetadataV1>,
25}
26
27///
28/// AdoptionReportError
29///
30#[derive(Debug, Eq, Error, PartialEq)]
31pub enum AdoptionReportError {
32    #[error("invalid config: {0}")]
33    InvalidConfig(String),
34
35    #[error("missing required [fleet].name in canic.toml")]
36    MissingFleetName,
37}
38
39///
40/// AdoptionProfileV1
41///
42#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
43pub enum AdoptionProfileV1 {
44    Brownfield,
45    Partial,
46    Standalone,
47    LeafOnly,
48    HybridExternalWasm,
49    Minimal,
50}
51
52///
53/// AdoptionReportV1
54///
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56pub struct AdoptionReportV1 {
57    pub schema_version: u32,
58    pub report_id: String,
59    pub generated_at: String,
60    pub fleet: String,
61    pub profile: AdoptionProfileV1,
62    pub inputs: AdoptionReportInputsV1,
63    pub summary: AdoptionReportSummaryV1,
64    pub role_findings: Vec<AdoptionRoleFindingV1>,
65    pub observed_canisters: Vec<AdoptionObservedCanisterFindingV1>,
66    pub recommendations: Vec<AdoptionRecommendationV1>,
67    pub blocked_actions: Vec<String>,
68    pub warnings: Vec<String>,
69}
70
71///
72/// AdoptionReportInputsV1
73///
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75pub struct AdoptionReportInputsV1 {
76    pub config_present: bool,
77    pub inventory_id: Option<String>,
78    pub artifact_manifest_id: Option<String>,
79    pub package_metadata_count: usize,
80    pub missing_or_stale_evidence: Vec<String>,
81}
82
83///
84/// AdoptionReportSummaryV1
85///
86#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87pub struct AdoptionReportSummaryV1 {
88    pub managed_configured_roles: usize,
89    pub declared_only_roles: usize,
90    pub attached_unobserved_roles: usize,
91    pub observed_only_canisters: usize,
92    pub user_controlled_canisters: usize,
93    pub external_controller_required: usize,
94    pub evidence_conflicts: usize,
95    pub mutating_actions_performed: usize,
96}
97
98///
99/// AdoptionRoleFindingV1
100///
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct AdoptionRoleFindingV1 {
103    pub fleet: String,
104    pub role: String,
105    pub classifications: Vec<AdoptionClassificationV1>,
106    pub declaration_state: AdoptionDeclarationStateV1,
107    pub topology_state: AdoptionTopologyStateV1,
108    pub package_state: AdoptionPackageStateV1,
109    pub observation_state: AdoptionObservationStateV1,
110    pub authority_state: AdoptionAuthorityStateV1,
111    pub artifact_state: AdoptionArtifactStateV1,
112    pub evidence: Vec<String>,
113    pub recommendations: Vec<AdoptionRecommendationV1>,
114    pub warnings: Vec<String>,
115}
116
117///
118/// AdoptionObservedCanisterFindingV1
119///
120#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
121pub struct AdoptionObservedCanisterFindingV1 {
122    pub canister_id: String,
123    pub matched_fleet: Option<String>,
124    pub matched_role: Option<String>,
125    pub confidence: AdoptionMatchConfidenceV1,
126    pub classifications: Vec<AdoptionClassificationV1>,
127    pub controllers: Vec<String>,
128    pub wasm_evidence: Option<String>,
129    pub deployment_target_evidence: Option<String>,
130    pub recommendations: Vec<AdoptionRecommendationV1>,
131    pub warnings: Vec<String>,
132}
133
134///
135/// AdoptionRecommendationV1
136///
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138pub struct AdoptionRecommendationV1 {
139    pub kind: String,
140    pub severity: AdoptionRecommendationSeverityV1,
141    pub description: String,
142    pub suggested_action: Option<String>,
143    pub suggested_action_effect: AdoptionSuggestedActionEffectV1,
144    pub suggested_action_support: AdoptionSuggestedActionSupportV1,
145    pub suggested_action_availability: AdoptionSuggestedActionAvailabilityV1,
146    pub operator_action_requirement: AdoptionOperatorActionRequirementV1,
147}
148
149///
150/// AdoptionPackageMetadataV1
151///
152#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153pub struct AdoptionPackageMetadataV1 {
154    pub package: String,
155    pub fleet: Option<String>,
156    pub role: Option<String>,
157}
158
159///
160/// AdoptionClassificationV1
161///
162#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
163pub enum AdoptionClassificationV1 {
164    Managed,
165    DeclaredOnly,
166    ObservedOnly,
167    AttachedUnobserved,
168    UserControlled,
169    ExternalControllerRequired,
170    ImportedPoolCandidate,
171    EvidenceConflict,
172}
173
174///
175/// AdoptionDeclarationStateV1
176///
177#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
178pub enum AdoptionDeclarationStateV1 {
179    Undeclared,
180    Declared,
181}
182
183///
184/// AdoptionTopologyStateV1
185///
186#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
187pub enum AdoptionTopologyStateV1 {
188    Unattached,
189    Attached,
190}
191
192///
193/// AdoptionObservationStateV1
194///
195#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
196pub enum AdoptionObservationStateV1 {
197    Unobserved,
198    Observed,
199    CandidateMatch,
200    ConflictingMatch,
201}
202
203///
204/// AdoptionAuthorityStateV1
205///
206#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
207pub enum AdoptionAuthorityStateV1 {
208    CanicAuthorized,
209    UserControlled,
210    External,
211    Unknown,
212}
213
214///
215/// AdoptionArtifactStateV1
216///
217#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub enum AdoptionArtifactStateV1 {
219    CanicBuilt,
220    ExternalWasm,
221    Unknown,
222}
223
224///
225/// AdoptionPackageStateV1
226///
227#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
228pub enum AdoptionPackageStateV1 {
229    UndeclaredRole,
230    NotChecked,
231    Matches,
232    MissingFleet,
233    MissingRole,
234    Mismatch,
235}
236
237///
238/// AdoptionMatchConfidenceV1
239///
240#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
241pub enum AdoptionMatchConfidenceV1 {
242    None,
243    Candidate,
244    ExplicitEvidence,
245    Conflict,
246}
247
248///
249/// AdoptionRecommendationSeverityV1
250///
251#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
252pub enum AdoptionRecommendationSeverityV1 {
253    Info,
254    Warning,
255    Blocked,
256}
257
258///
259/// AdoptionSuggestedActionEffectV1
260///
261#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
262pub enum AdoptionSuggestedActionEffectV1 {
263    ReadOnly,
264    MutatesState,
265}
266
267///
268/// AdoptionSuggestedActionSupportV1
269///
270#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
271pub enum AdoptionSuggestedActionSupportV1 {
272    SupportedByAdoption,
273    UnsupportedByAdoption,
274}
275
276///
277/// AdoptionSuggestedActionAvailabilityV1
278///
279#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
280pub enum AdoptionSuggestedActionAvailabilityV1 {
281    AllowedIn0500,
282    BlockedIn0500,
283}
284
285///
286/// AdoptionOperatorActionRequirementV1
287///
288#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
289pub enum AdoptionOperatorActionRequirementV1 {
290    Required,
291    NotRequired,
292}
293
294struct DeclaredRoleFindingInput<'a> {
295    profile: AdoptionProfileV1,
296    fleet: &'a str,
297    role: &'a CanisterRole,
298    package: &'a str,
299    attached: bool,
300    observed: Option<&'a [&'a crate::deployment_truth::ObservedCanisterV1]>,
301    duplicate_observation: bool,
302    packages_by_path: &'a BTreeMap<String, AdoptionPackageMetadataV1>,
303    artifact_state: Option<AdoptionArtifactStateV1>,
304    artifact_conflict: bool,
305    artifact_evidence: Option<&'a [String]>,
306}
307
308struct ObservedOnlyRoleFindingInput<'a> {
309    profile: AdoptionProfileV1,
310    fleet: &'a str,
311    role: &'a str,
312    observed: &'a [&'a crate::deployment_truth::ObservedCanisterV1],
313    duplicate_observation: bool,
314    artifact_state: Option<AdoptionArtifactStateV1>,
315    artifact_conflict: bool,
316    artifact_evidence: Option<&'a [String]>,
317}
318
319///
320/// adoption_report_from_config_source
321///
322pub fn adoption_report_from_config_source(
323    request: AdoptionReportRequest<'_>,
324) -> Result<AdoptionReportV1, AdoptionReportError> {
325    let config = parse_config_model(request.config_source)
326        .map_err(|err| AdoptionReportError::InvalidConfig(err.to_string()))?;
327    let fleet = config
328        .fleet_name()
329        .ok_or(AdoptionReportError::MissingFleetName)?
330        .to_string();
331    let attached_roles = config.attached_roles();
332    let observed_by_role = observed_canisters_by_role(request.inventory);
333    let observed_duplicate_roles = duplicate_observed_roles(&observed_by_role);
334    let packages_by_path = package_metadata_by_path(request.package_metadata);
335    let artifacts_by_role = artifact_states_by_role(request.artifact_manifest, request.inventory);
336    let artifact_conflict_roles =
337        artifact_conflict_roles(request.artifact_manifest, request.inventory);
338    let artifact_evidence_by_role =
339        artifact_evidence_by_role(request.artifact_manifest, request.inventory);
340    let declared_roles = config.roles.keys().cloned().collect::<BTreeSet<_>>();
341
342    let mut role_findings = Vec::new();
343    let mut seen_roles = BTreeSet::new();
344
345    for (role, declaration) in &config.roles {
346        seen_roles.insert(role.as_str().to_string());
347        role_findings.push(role_finding_for_declared_role(DeclaredRoleFindingInput {
348            profile: request.profile,
349            fleet: &fleet,
350            role,
351            package: declaration.package.as_str(),
352            attached: attached_roles.contains(role),
353            observed: observed_by_role.get(role.as_str()).map(Vec::as_slice),
354            duplicate_observation: observed_duplicate_roles.contains(role.as_str()),
355            packages_by_path: &packages_by_path,
356            artifact_state: artifacts_by_role.get(role.as_str()).copied(),
357            artifact_conflict: artifact_conflict_roles.contains(role.as_str()),
358            artifact_evidence: artifact_evidence_by_role
359                .get(role.as_str())
360                .map(Vec::as_slice),
361        }));
362    }
363
364    for (role, observed) in &observed_by_role {
365        if seen_roles.contains(role) {
366            continue;
367        }
368        role_findings.push(role_finding_for_observed_only_role(
369            ObservedOnlyRoleFindingInput {
370                profile: request.profile,
371                fleet: &fleet,
372                role,
373                observed,
374                duplicate_observation: observed_duplicate_roles.contains(role),
375                artifact_state: artifacts_by_role.get(role.as_str()).copied(),
376                artifact_conflict: artifact_conflict_roles.contains(role),
377                artifact_evidence: artifact_evidence_by_role
378                    .get(role.as_str())
379                    .map(Vec::as_slice),
380            },
381        ));
382    }
383
384    role_findings.sort_by(|left, right| left.role.cmp(&right.role));
385
386    let observed_canisters = observed_canister_findings(
387        request.profile,
388        &fleet,
389        request.inventory,
390        &declared_roles,
391        &attached_roles,
392    );
393    let summary = report_summary(&role_findings, &observed_canisters);
394    let mut recommendations = Vec::new();
395    for finding in &role_findings {
396        recommendations.extend(finding.recommendations.clone());
397    }
398    for finding in &observed_canisters {
399        recommendations.extend(finding.recommendations.clone());
400    }
401
402    Ok(AdoptionReportV1 {
403        schema_version: ADOPTION_REPORT_SCHEMA_VERSION,
404        report_id: request.report_id.to_string(),
405        generated_at: request.generated_at.to_string(),
406        fleet,
407        profile: request.profile,
408        inputs: AdoptionReportInputsV1 {
409            config_present: true,
410            inventory_id: request
411                .inventory
412                .map(|inventory| inventory.inventory_id.clone()),
413            artifact_manifest_id: request
414                .artifact_manifest
415                .map(|manifest| manifest.manifest_id.clone()),
416            package_metadata_count: packages_by_path.len(),
417            missing_or_stale_evidence: missing_evidence(
418                request.inventory,
419                request.artifact_manifest,
420            ),
421        },
422        summary,
423        role_findings,
424        observed_canisters,
425        recommendations,
426        blocked_actions: blocked_actions(),
427        warnings: Vec::new(),
428    })
429}
430
431fn role_finding_for_declared_role(input: DeclaredRoleFindingInput<'_>) -> AdoptionRoleFindingV1 {
432    let role_name = input.role.as_str().to_string();
433    let observed = input.observed.unwrap_or_default();
434    let observed_any = !observed.is_empty();
435    let mut classifications = BTreeSet::new();
436    let mut evidence = Vec::new();
437    let mut warnings = Vec::new();
438    let mut recommendations = Vec::new();
439
440    evidence.push("role declaration exists".to_string());
441    if input.attached {
442        evidence.push("topology attachment exists".to_string());
443        classifications.insert(AdoptionClassificationV1::Managed);
444    } else {
445        evidence.push("no topology attachment exists".to_string());
446        classifications.insert(AdoptionClassificationV1::DeclaredOnly);
447        if input.profile == AdoptionProfileV1::LeafOnly
448            && is_leaf_only_authority_sensitive_role(&role_name)
449        {
450            warnings.push(
451                "leaf-only profile leaves authority-sensitive declared roles unattached"
452                    .to_string(),
453            );
454        } else {
455            recommendations.push(attach_later_recommendation(input.fleet, &role_name));
456        }
457    }
458
459    if input.attached && !observed_any {
460        classifications.insert(AdoptionClassificationV1::AttachedUnobserved);
461        warnings.push("deployment-truth evidence does not confirm this attached role".to_string());
462    }
463
464    for canister in observed {
465        evidence.push(format!("observed canister {}", canister.canister_id));
466        if let Some(hash) = &canister.module_hash {
467            evidence.push(format!("observed canister module_hash={hash}"));
468        }
469    }
470    if let Some(artifact_evidence) = input.artifact_evidence {
471        evidence.extend(artifact_evidence.iter().cloned());
472    }
473
474    let authority_state = combined_authority_state(observed);
475    if matches!(
476        authority_state,
477        AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
478    ) {
479        classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
480    }
481    if matches!(authority_state, AdoptionAuthorityStateV1::UserControlled) {
482        classifications.insert(AdoptionClassificationV1::UserControlled);
483    }
484
485    let package_state = package_state(
486        input.package,
487        input.fleet,
488        &role_name,
489        input.packages_by_path,
490    );
491    if matches!(
492        package_state,
493        AdoptionPackageStateV1::MissingFleet
494            | AdoptionPackageStateV1::MissingRole
495            | AdoptionPackageStateV1::Mismatch
496    ) {
497        classifications.insert(AdoptionClassificationV1::EvidenceConflict);
498        warnings.push("package metadata does not match declared fleet role".to_string());
499    }
500
501    if input.duplicate_observation {
502        classifications.insert(AdoptionClassificationV1::EvidenceConflict);
503        warnings.push("deployment evidence contains conflicting role facts".to_string());
504    }
505
506    if input.artifact_conflict {
507        classifications.insert(AdoptionClassificationV1::EvidenceConflict);
508        warnings.push("artifact evidence contains conflicting role facts".to_string());
509    }
510
511    let artifact_state = input
512        .artifact_state
513        .unwrap_or_else(|| artifact_state_from_observed(observed));
514    if input.profile == AdoptionProfileV1::HybridExternalWasm
515        && artifact_state == AdoptionArtifactStateV1::ExternalWasm
516    {
517        warnings.push(
518            "external Wasm evidence is reported only; artifact registry import is outside adoption reporting"
519                .to_string(),
520        );
521    }
522
523    AdoptionRoleFindingV1 {
524        fleet: input.fleet.to_string(),
525        role: role_name,
526        classifications: classifications.into_iter().collect(),
527        declaration_state: AdoptionDeclarationStateV1::Declared,
528        topology_state: if input.attached {
529            AdoptionTopologyStateV1::Attached
530        } else {
531            AdoptionTopologyStateV1::Unattached
532        },
533        package_state,
534        observation_state: observation_state(observed_any, input.duplicate_observation),
535        authority_state,
536        artifact_state,
537        evidence,
538        recommendations,
539        warnings,
540    }
541}
542
543fn role_finding_for_observed_only_role(
544    input: ObservedOnlyRoleFindingInput<'_>,
545) -> AdoptionRoleFindingV1 {
546    let mut classifications = BTreeSet::new();
547    classifications.insert(AdoptionClassificationV1::ObservedOnly);
548    if input.duplicate_observation {
549        classifications.insert(AdoptionClassificationV1::EvidenceConflict);
550    }
551    if input.artifact_conflict {
552        classifications.insert(AdoptionClassificationV1::EvidenceConflict);
553    }
554
555    let authority_state = combined_authority_state(input.observed);
556    if matches!(authority_state, AdoptionAuthorityStateV1::UserControlled) {
557        classifications.insert(AdoptionClassificationV1::UserControlled);
558    }
559    if matches!(
560        authority_state,
561        AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
562    ) {
563        classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
564    }
565
566    let artifact_state = input
567        .artifact_state
568        .unwrap_or_else(|| artifact_state_from_observed(input.observed));
569    let mut evidence = input
570        .observed
571        .iter()
572        .flat_map(|canister| {
573            let mut evidence = vec![format!("observed canister {}", canister.canister_id)];
574            if let Some(hash) = &canister.module_hash {
575                evidence.push(format!("observed canister module_hash={hash}"));
576            }
577            evidence
578        })
579        .collect::<Vec<_>>();
580    if let Some(artifact_evidence) = input.artifact_evidence {
581        evidence.extend(artifact_evidence.iter().cloned());
582    }
583
584    let mut warnings = observed_only_warnings(input.profile, input.role);
585    if input.artifact_conflict {
586        warnings.push("artifact evidence contains conflicting role facts".to_string());
587    }
588    if input.profile == AdoptionProfileV1::HybridExternalWasm
589        && artifact_state == AdoptionArtifactStateV1::ExternalWasm
590    {
591        warnings.push(
592            "external Wasm evidence is reported only; artifact registry import is outside adoption reporting"
593                .to_string(),
594        );
595    }
596
597    AdoptionRoleFindingV1 {
598        fleet: input.fleet.to_string(),
599        role: input.role.to_string(),
600        classifications: classifications.into_iter().collect(),
601        declaration_state: AdoptionDeclarationStateV1::Undeclared,
602        topology_state: AdoptionTopologyStateV1::Unattached,
603        package_state: AdoptionPackageStateV1::UndeclaredRole,
604        observation_state: observation_state(true, input.duplicate_observation),
605        authority_state,
606        artifact_state,
607        evidence,
608        recommendations: observed_only_recommendations(
609            input.profile,
610            input.fleet,
611            input.role,
612            authority_state,
613        ),
614        warnings,
615    }
616}
617
618fn observed_canister_findings(
619    profile: AdoptionProfileV1,
620    fleet: &str,
621    inventory: Option<&DeploymentInventoryV1>,
622    declarations: &BTreeSet<CanisterRole>,
623    attached_roles: &BTreeSet<CanisterRole>,
624) -> Vec<AdoptionObservedCanisterFindingV1> {
625    let Some(inventory) = inventory else {
626        return Vec::new();
627    };
628
629    let mut findings = Vec::new();
630    for canister in &inventory.observed_canisters {
631        let role = canister.role.as_deref();
632        let declared =
633            role.is_some_and(|role| declarations.contains(&CanisterRole::owned(role.to_string())));
634        let attached = role
635            .is_some_and(|role| attached_roles.contains(&CanisterRole::owned(role.to_string())));
636        let mut classifications = BTreeSet::new();
637        if role.is_none() || !declared {
638            classifications.insert(AdoptionClassificationV1::ObservedOnly);
639        }
640        if matches!(
641            authority_state_for_control_class(canister.control_class),
642            AdoptionAuthorityStateV1::UserControlled
643        ) {
644            classifications.insert(AdoptionClassificationV1::UserControlled);
645        }
646        if matches!(
647            authority_state_for_control_class(canister.control_class),
648            AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
649        ) {
650            classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
651        }
652
653        findings.push(AdoptionObservedCanisterFindingV1 {
654            canister_id: canister.canister_id.clone(),
655            matched_fleet: role.map(|_| fleet.to_string()),
656            matched_role: role.map(str::to_string),
657            confidence: match (role, declared, attached) {
658                (Some(_), true, true) => AdoptionMatchConfidenceV1::ExplicitEvidence,
659                (Some(_), _, _) => AdoptionMatchConfidenceV1::Candidate,
660                (None, _, _) => AdoptionMatchConfidenceV1::None,
661            },
662            classifications: classifications.into_iter().collect(),
663            controllers: canister.controllers.clone(),
664            wasm_evidence: canister
665                .module_hash
666                .as_ref()
667                .map(|hash| format!("module_hash={hash}")),
668            deployment_target_evidence: Some(inventory.inventory_id.clone()),
669            recommendations: match (role, declared) {
670                (Some(role), false) => observed_only_recommendations(
671                    profile,
672                    fleet,
673                    role,
674                    authority_state_for_control_class(canister.control_class),
675                ),
676                _ => Vec::new(),
677            },
678            warnings: role
679                .map(|role| observed_only_warnings(profile, role))
680                .unwrap_or_default(),
681        });
682    }
683
684    for pool in &inventory.observed_pool {
685        findings.push(AdoptionObservedCanisterFindingV1 {
686            canister_id: pool.canister_id.clone(),
687            matched_fleet: pool.role.as_ref().map(|_| fleet.to_string()),
688            matched_role: pool.role.clone(),
689            confidence: AdoptionMatchConfidenceV1::Candidate,
690            classifications: vec![AdoptionClassificationV1::ImportedPoolCandidate],
691            controllers: Vec::new(),
692            wasm_evidence: None,
693            deployment_target_evidence: Some(format!("pool={}", pool.pool)),
694            recommendations: Vec::new(),
695            warnings: vec!["pool import is outside 0.50.0".to_string()],
696        });
697    }
698
699    findings.sort_by(|left, right| left.canister_id.cmp(&right.canister_id));
700    findings
701}
702
703fn observed_only_recommendations(
704    profile: AdoptionProfileV1,
705    fleet: &str,
706    role: &str,
707    authority_state: AdoptionAuthorityStateV1,
708) -> Vec<AdoptionRecommendationV1> {
709    if profile == AdoptionProfileV1::LeafOnly && is_leaf_only_authority_sensitive_role(role) {
710        return Vec::new();
711    }
712
713    if authority_state != AdoptionAuthorityStateV1::CanicAuthorized {
714        return vec![review_authority_before_declaration_recommendation(
715            fleet,
716            role,
717            authority_state,
718        )];
719    }
720
721    vec![declare_role_recommendation(fleet, role)]
722}
723
724fn observed_only_warnings(profile: AdoptionProfileV1, role: &str) -> Vec<String> {
725    if profile == AdoptionProfileV1::LeafOnly && is_leaf_only_authority_sensitive_role(role) {
726        return vec![
727            "leaf-only profile leaves authority-sensitive observed roles external".to_string(),
728        ];
729    }
730
731    Vec::new()
732}
733
734fn is_leaf_only_authority_sensitive_role(role: &str) -> bool {
735    matches!(role, "root" | "governance" | "governance_root")
736}
737
738fn package_state(
739    package: &str,
740    fleet: &str,
741    role: &str,
742    packages_by_path: &BTreeMap<String, AdoptionPackageMetadataV1>,
743) -> AdoptionPackageStateV1 {
744    let Some(metadata) = packages_by_path.get(package) else {
745        return AdoptionPackageStateV1::NotChecked;
746    };
747    if metadata.fleet.is_none() {
748        return AdoptionPackageStateV1::MissingFleet;
749    }
750    if metadata.role.is_none() {
751        return AdoptionPackageStateV1::MissingRole;
752    }
753    if metadata.fleet.as_deref() == Some(fleet) && metadata.role.as_deref() == Some(role) {
754        AdoptionPackageStateV1::Matches
755    } else {
756        AdoptionPackageStateV1::Mismatch
757    }
758}
759
760fn observed_canisters_by_role(
761    inventory: Option<&DeploymentInventoryV1>,
762) -> BTreeMap<String, Vec<&crate::deployment_truth::ObservedCanisterV1>> {
763    let mut observed = BTreeMap::<String, Vec<&crate::deployment_truth::ObservedCanisterV1>>::new();
764    let Some(inventory) = inventory else {
765        return observed;
766    };
767
768    for canister in &inventory.observed_canisters {
769        if let Some(role) = &canister.role {
770            observed.entry(role.clone()).or_default().push(canister);
771        }
772    }
773    observed
774}
775
776fn duplicate_observed_roles(
777    observed_by_role: &BTreeMap<String, Vec<&crate::deployment_truth::ObservedCanisterV1>>,
778) -> BTreeSet<String> {
779    observed_by_role
780        .iter()
781        .filter(|(_, canisters)| canisters.len() > 1)
782        .map(|(role, _)| role.clone())
783        .collect()
784}
785
786fn package_metadata_by_path(
787    metadata: Vec<AdoptionPackageMetadataV1>,
788) -> BTreeMap<String, AdoptionPackageMetadataV1> {
789    metadata
790        .into_iter()
791        .map(|metadata| (metadata.package.clone(), metadata))
792        .collect()
793}
794
795fn artifact_states_by_role(
796    manifest: Option<&RoleArtifactManifestV1>,
797    inventory: Option<&DeploymentInventoryV1>,
798) -> BTreeMap<String, AdoptionArtifactStateV1> {
799    let mut states = BTreeMap::new();
800
801    if let Some(manifest) = manifest {
802        for artifact in &manifest.role_artifacts {
803            states.insert(
804                artifact.role.clone(),
805                artifact_state_for_source(artifact.source),
806            );
807        }
808    }
809
810    if let Some(inventory) = inventory {
811        for artifact in &inventory.observed_artifacts {
812            states
813                .entry(artifact.role.clone())
814                .or_insert_with(|| artifact_state_for_source(artifact.source));
815        }
816    }
817
818    states
819}
820
821fn artifact_conflict_roles(
822    manifest: Option<&RoleArtifactManifestV1>,
823    inventory: Option<&DeploymentInventoryV1>,
824) -> BTreeSet<String> {
825    let mut manifest_states = BTreeMap::new();
826    let mut conflict_roles = BTreeSet::new();
827
828    if let Some(manifest) = manifest {
829        for artifact in &manifest.role_artifacts {
830            let state = artifact_state_for_source(artifact.source);
831            if manifest_states
832                .insert(artifact.role.clone(), state)
833                .is_some_and(|previous| previous != state)
834            {
835                conflict_roles.insert(artifact.role.clone());
836            }
837        }
838    }
839
840    if let Some(inventory) = inventory {
841        for artifact in &inventory.observed_artifacts {
842            let state = artifact_state_for_source(artifact.source);
843            if manifest_states
844                .get(&artifact.role)
845                .is_some_and(|previous| *previous != state)
846            {
847                conflict_roles.insert(artifact.role.clone());
848            }
849        }
850    }
851
852    conflict_roles
853}
854
855fn artifact_evidence_by_role(
856    manifest: Option<&RoleArtifactManifestV1>,
857    inventory: Option<&DeploymentInventoryV1>,
858) -> BTreeMap<String, Vec<String>> {
859    let mut evidence = BTreeMap::<String, Vec<String>>::new();
860
861    if let Some(manifest) = manifest {
862        for artifact in &manifest.role_artifacts {
863            let role_evidence = evidence.entry(artifact.role.clone()).or_default();
864            role_evidence.push(format!(
865                "artifact manifest source={}",
866                artifact_source_label(artifact.source)
867            ));
868            if let Some(hash) = &artifact.installed_module_hash {
869                role_evidence.push(format!("artifact manifest installed_module_hash={hash}"));
870            }
871            if let Some(hash) = &artifact.wasm_sha256 {
872                role_evidence.push(format!("artifact manifest wasm_sha256={hash}"));
873            }
874            if let Some(hash) = &artifact.wasm_gz_sha256 {
875                role_evidence.push(format!("artifact manifest wasm_gz_sha256={hash}"));
876            }
877        }
878    }
879
880    if let Some(inventory) = inventory {
881        for artifact in &inventory.observed_artifacts {
882            let role_evidence = evidence.entry(artifact.role.clone()).or_default();
883            role_evidence.push(format!(
884                "observed artifact source={} path={}",
885                artifact_source_label(artifact.source),
886                artifact.artifact_path
887            ));
888            if let Some(hash) = &artifact.file_sha256 {
889                role_evidence.push(format!("observed artifact file_sha256={hash}"));
890            }
891            if let Some(hash) = &artifact.payload_sha256 {
892                role_evidence.push(format!("observed artifact payload_sha256={hash}"));
893            }
894            if let Some(size) = artifact.payload_size_bytes {
895                role_evidence.push(format!("observed artifact payload_size_bytes={size}"));
896            }
897        }
898    }
899
900    evidence
901}
902
903const fn artifact_state_for_source(source: ArtifactSourceV1) -> AdoptionArtifactStateV1 {
904    match source {
905        ArtifactSourceV1::External | ArtifactSourceV1::Unknown => {
906            AdoptionArtifactStateV1::ExternalWasm
907        }
908        ArtifactSourceV1::LocalBuild
909        | ArtifactSourceV1::ReleaseSet
910        | ArtifactSourceV1::WasmStore => AdoptionArtifactStateV1::CanicBuilt,
911    }
912}
913
914const fn artifact_source_label(source: ArtifactSourceV1) -> &'static str {
915    match source {
916        ArtifactSourceV1::LocalBuild => "local-build",
917        ArtifactSourceV1::ReleaseSet => "release-set",
918        ArtifactSourceV1::WasmStore => "wasm-store",
919        ArtifactSourceV1::External => "external",
920        ArtifactSourceV1::Unknown => "unknown",
921    }
922}
923
924fn combined_authority_state(
925    observed: &[&crate::deployment_truth::ObservedCanisterV1],
926) -> AdoptionAuthorityStateV1 {
927    let mut states = observed
928        .iter()
929        .map(|canister| authority_state_for_control_class(canister.control_class))
930        .collect::<BTreeSet<_>>();
931    if states.is_empty() {
932        return AdoptionAuthorityStateV1::Unknown;
933    }
934    if states.remove(&AdoptionAuthorityStateV1::UserControlled) {
935        return AdoptionAuthorityStateV1::UserControlled;
936    }
937    if states.remove(&AdoptionAuthorityStateV1::External) {
938        return AdoptionAuthorityStateV1::External;
939    }
940    if states.remove(&AdoptionAuthorityStateV1::Unknown) {
941        return AdoptionAuthorityStateV1::Unknown;
942    }
943    AdoptionAuthorityStateV1::CanicAuthorized
944}
945
946const fn authority_state_for_control_class(
947    control_class: CanisterControlClassV1,
948) -> AdoptionAuthorityStateV1 {
949    match control_class {
950        CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::CanicManagedPool => {
951            AdoptionAuthorityStateV1::CanicAuthorized
952        }
953        CanisterControlClassV1::UserControlled => AdoptionAuthorityStateV1::UserControlled,
954        CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
955            AdoptionAuthorityStateV1::External
956        }
957        CanisterControlClassV1::UnknownUnsafe => AdoptionAuthorityStateV1::Unknown,
958    }
959}
960
961const fn observation_state(observed: bool, conflict: bool) -> AdoptionObservationStateV1 {
962    match (observed, conflict) {
963        (_, true) => AdoptionObservationStateV1::ConflictingMatch,
964        (true, false) => AdoptionObservationStateV1::Observed,
965        (false, false) => AdoptionObservationStateV1::Unobserved,
966    }
967}
968
969fn artifact_state_from_observed(
970    observed: &[&crate::deployment_truth::ObservedCanisterV1],
971) -> AdoptionArtifactStateV1 {
972    if observed
973        .iter()
974        .any(|canister| canister.module_hash.is_some())
975    {
976        AdoptionArtifactStateV1::ExternalWasm
977    } else {
978        AdoptionArtifactStateV1::Unknown
979    }
980}
981
982fn missing_evidence(
983    inventory: Option<&DeploymentInventoryV1>,
984    artifact_manifest: Option<&RoleArtifactManifestV1>,
985) -> Vec<String> {
986    let mut evidence = Vec::new();
987
988    if let Some(inventory) = inventory {
989        evidence.extend(inventory.unresolved_observations.iter().map(|gap| {
990            format!(
991                "unresolved inventory observation {}: {}",
992                gap.key, gap.description
993            )
994        }));
995    } else {
996        evidence.push("deployment inventory was not supplied".to_string());
997    }
998
999    if let Some(manifest) = artifact_manifest {
1000        evidence.extend(manifest.unresolved_artifacts.iter().map(|gap| {
1001            format!(
1002                "unresolved artifact evidence {}: {}",
1003                gap.key, gap.description
1004            )
1005        }));
1006    }
1007
1008    evidence
1009}
1010
1011fn report_summary(
1012    role_findings: &[AdoptionRoleFindingV1],
1013    observed_findings: &[AdoptionObservedCanisterFindingV1],
1014) -> AdoptionReportSummaryV1 {
1015    AdoptionReportSummaryV1 {
1016        managed_configured_roles: role_findings
1017            .iter()
1018            .filter(|finding| {
1019                finding
1020                    .classifications
1021                    .contains(&AdoptionClassificationV1::Managed)
1022            })
1023            .count(),
1024        declared_only_roles: role_findings
1025            .iter()
1026            .filter(|finding| {
1027                finding
1028                    .classifications
1029                    .contains(&AdoptionClassificationV1::DeclaredOnly)
1030            })
1031            .count(),
1032        attached_unobserved_roles: role_findings
1033            .iter()
1034            .filter(|finding| {
1035                finding
1036                    .classifications
1037                    .contains(&AdoptionClassificationV1::AttachedUnobserved)
1038            })
1039            .count(),
1040        observed_only_canisters: observed_findings
1041            .iter()
1042            .filter(|finding| {
1043                finding
1044                    .classifications
1045                    .contains(&AdoptionClassificationV1::ObservedOnly)
1046            })
1047            .count(),
1048        user_controlled_canisters: observed_findings
1049            .iter()
1050            .filter(|finding| {
1051                finding
1052                    .classifications
1053                    .contains(&AdoptionClassificationV1::UserControlled)
1054            })
1055            .count(),
1056        external_controller_required: role_findings
1057            .iter()
1058            .filter(|finding| {
1059                finding
1060                    .classifications
1061                    .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1062            })
1063            .count(),
1064        evidence_conflicts: role_findings
1065            .iter()
1066            .filter(|finding| {
1067                finding
1068                    .classifications
1069                    .contains(&AdoptionClassificationV1::EvidenceConflict)
1070            })
1071            .count(),
1072        mutating_actions_performed: 0,
1073    }
1074}
1075
1076fn declare_role_recommendation(fleet: &str, role: &str) -> AdoptionRecommendationV1 {
1077    AdoptionRecommendationV1 {
1078        kind: "declare_role".to_string(),
1079        severity: AdoptionRecommendationSeverityV1::Info,
1080        description: format!("declare observed role candidate {fleet}.{role} before attachment"),
1081        suggested_action: Some(format!(
1082            "canic fleet role declare {fleet} {role} --package <path>"
1083        )),
1084        suggested_action_effect: AdoptionSuggestedActionEffectV1::MutatesState,
1085        suggested_action_support: AdoptionSuggestedActionSupportV1::UnsupportedByAdoption,
1086        suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::BlockedIn0500,
1087        operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1088    }
1089}
1090
1091fn review_authority_before_declaration_recommendation(
1092    fleet: &str,
1093    role: &str,
1094    authority_state: AdoptionAuthorityStateV1,
1095) -> AdoptionRecommendationV1 {
1096    AdoptionRecommendationV1 {
1097        kind: "review_authority_before_declaration".to_string(),
1098        severity: AdoptionRecommendationSeverityV1::Warning,
1099        description: format!(
1100            "review {fleet}.{role} authority before declaring observed role candidate ({})",
1101            adoption_authority_state_label(authority_state)
1102        ),
1103        suggested_action: None,
1104        suggested_action_effect: AdoptionSuggestedActionEffectV1::ReadOnly,
1105        suggested_action_support: AdoptionSuggestedActionSupportV1::SupportedByAdoption,
1106        suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::AllowedIn0500,
1107        operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1108    }
1109}
1110
1111const fn adoption_authority_state_label(authority_state: AdoptionAuthorityStateV1) -> &'static str {
1112    match authority_state {
1113        AdoptionAuthorityStateV1::CanicAuthorized => "canic-authorized",
1114        AdoptionAuthorityStateV1::UserControlled => "user-controlled",
1115        AdoptionAuthorityStateV1::External => "external",
1116        AdoptionAuthorityStateV1::Unknown => "unknown",
1117    }
1118}
1119
1120fn attach_later_recommendation(fleet: &str, role: &str) -> AdoptionRecommendationV1 {
1121    AdoptionRecommendationV1 {
1122        kind: "attach_role_later".to_string(),
1123        severity: AdoptionRecommendationSeverityV1::Info,
1124        description: format!("attach {fleet}.{role} explicitly only when topology is ready"),
1125        suggested_action: Some(format!(
1126            "canic fleet role attach {fleet} {role} --subnet <subnet>"
1127        )),
1128        suggested_action_effect: AdoptionSuggestedActionEffectV1::MutatesState,
1129        suggested_action_support: AdoptionSuggestedActionSupportV1::UnsupportedByAdoption,
1130        suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::BlockedIn0500,
1131        operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1132    }
1133}
1134
1135fn blocked_actions() -> Vec<String> {
1136    [
1137        "controller changes",
1138        "topology attachment",
1139        "pool import",
1140        "install",
1141        "upgrade",
1142        "reinstall",
1143        "stop",
1144        "start",
1145        "delete",
1146        "deploy",
1147        "promote",
1148        "rollback",
1149        "artifact registry import",
1150    ]
1151    .into_iter()
1152    .map(str::to_string)
1153    .collect()
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159    use crate::deployment_truth::{
1160        ArtifactDigestSourceV1, DeploymentInventoryV1, DeploymentObservationGapV1,
1161        DeploymentRootObservationSourceV1, DeploymentRootObservationV1, LocalDeploymentConfigV1,
1162        ObservationStatusV1, ObservedArtifactV1, ObservedCanisterV1, ObservedPoolCanisterV1,
1163        RoleArtifactManifestV1, RoleArtifactV1, VerifierReadinessObservationV1,
1164    };
1165
1166    const CONFIG: &str = r#"
1167controllers = []
1168app_index = []
1169
1170[fleet]
1171name = "demo"
1172
1173[roles.root]
1174kind = "root"
1175package = "root"
1176
1177[roles.api]
1178kind = "canister"
1179package = "api"
1180
1181[roles.store]
1182kind = "canister"
1183package = "store"
1184
1185[subnets.prime.canisters.root]
1186kind = "root"
1187
1188[subnets.prime.canisters.api]
1189kind = "singleton"
1190"#;
1191
1192    const BROWNFIELD_CONFIG: &str = r#"
1193controllers = []
1194app_index = []
1195
1196[fleet]
1197name = "demo"
1198
1199[roles.root]
1200kind = "root"
1201package = "root"
1202
1203[subnets.prime.canisters.root]
1204kind = "root"
1205"#;
1206
1207    const STANDALONE_CONFIG: &str = r#"
1208controllers = []
1209app_index = []
1210
1211[fleet]
1212name = "demo"
1213
1214[roles.worker]
1215kind = "canister"
1216package = "worker"
1217"#;
1218
1219    const LEAF_ONLY_CONFIG: &str = r#"
1220controllers = []
1221app_index = []
1222
1223[fleet]
1224name = "demo"
1225
1226[roles.root]
1227kind = "root"
1228package = "root"
1229
1230[roles.app]
1231kind = "canister"
1232package = "app"
1233
1234[subnets.prime.canisters.app]
1235kind = "singleton"
1236
1237[subnets.prime.canisters.root]
1238kind = "root"
1239"#;
1240
1241    #[test]
1242    fn adoption_report_preserves_declared_only_as_non_deployable() {
1243        let report = report(CONFIG, None, Vec::new());
1244        let store = role(&report, "store");
1245
1246        assert_eq!(
1247            store.declaration_state,
1248            AdoptionDeclarationStateV1::Declared
1249        );
1250        assert_eq!(store.topology_state, AdoptionTopologyStateV1::Unattached);
1251        assert!(
1252            store
1253                .classifications
1254                .contains(&AdoptionClassificationV1::DeclaredOnly)
1255        );
1256        assert_eq!(report.summary.declared_only_roles, 1);
1257        assert_eq!(report.summary.mutating_actions_performed, 0);
1258        assert!(store.recommendations.iter().all(|recommendation| {
1259            recommendation.suggested_action_availability
1260                == AdoptionSuggestedActionAvailabilityV1::BlockedIn0500
1261        }));
1262        assert!(report.blocked_actions.contains(&"install".to_string()));
1263    }
1264
1265    #[test]
1266    fn brownfield_fixture_reports_observed_roles_without_claiming_them() {
1267        let inventory = inventory(vec![
1268            observed_canister(
1269                "aaaaa-aa",
1270                Some("root"),
1271                CanisterControlClassV1::DeploymentControlled,
1272                None,
1273            ),
1274            observed_canister(
1275                "bbbbb-bb",
1276                Some("api"),
1277                CanisterControlClassV1::UserControlled,
1278                Some("api-hash"),
1279            ),
1280            observed_canister(
1281                "ccccc-cc",
1282                Some("store"),
1283                CanisterControlClassV1::ExternallyImported,
1284                Some("store-hash"),
1285            ),
1286            observed_canister(
1287                "ddddd-dd",
1288                None,
1289                CanisterControlClassV1::UnknownUnsafe,
1290                Some("unknown-hash"),
1291            ),
1292        ]);
1293        let report = report_with_profile(
1294            AdoptionProfileV1::Brownfield,
1295            BROWNFIELD_CONFIG,
1296            Some(&inventory),
1297            Vec::new(),
1298        );
1299        let api = role(&report, "api");
1300        let store = role(&report, "store");
1301
1302        assert_eq!(report.profile, AdoptionProfileV1::Brownfield);
1303        assert_eq!(report.summary.managed_configured_roles, 1);
1304        assert_eq!(report.summary.observed_only_canisters, 3);
1305        assert_eq!(report.summary.user_controlled_canisters, 1);
1306        assert_eq!(report.summary.external_controller_required, 2);
1307        assert_eq!(report.summary.mutating_actions_performed, 0);
1308        assert!(
1309            api.classifications
1310                .contains(&AdoptionClassificationV1::ObservedOnly)
1311        );
1312        assert_eq!(
1313            api.authority_state,
1314            AdoptionAuthorityStateV1::UserControlled
1315        );
1316        assert!(
1317            store
1318                .classifications
1319                .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1320        );
1321        assert_eq!(store.artifact_state, AdoptionArtifactStateV1::ExternalWasm);
1322        assert!(
1323            report
1324                .recommendations
1325                .iter()
1326                .filter(|recommendation| recommendation.kind == "declare_role")
1327                .all(|recommendation| recommendation.suggested_action_support
1328                    == AdoptionSuggestedActionSupportV1::UnsupportedByAdoption
1329                    && recommendation.suggested_action_availability
1330                        == AdoptionSuggestedActionAvailabilityV1::BlockedIn0500)
1331        );
1332    }
1333
1334    #[test]
1335    fn partial_fixture_preserves_managed_and_external_roles_separately() {
1336        let inventory = inventory(vec![
1337            observed_canister(
1338                "aaaaa-aa",
1339                Some("root"),
1340                CanisterControlClassV1::DeploymentControlled,
1341                None,
1342            ),
1343            observed_canister(
1344                "bbbbb-bb",
1345                Some("api"),
1346                CanisterControlClassV1::DeploymentControlled,
1347                Some("api-hash"),
1348            ),
1349            observed_canister(
1350                "ccccc-cc",
1351                Some("external_app"),
1352                CanisterControlClassV1::UserControlled,
1353                Some("external_app-hash"),
1354            ),
1355        ]);
1356        let report = report_with_profile(
1357            AdoptionProfileV1::Partial,
1358            CONFIG,
1359            Some(&inventory),
1360            Vec::new(),
1361        );
1362        let api = role(&report, "api");
1363        let store = role(&report, "store");
1364        let external_app = role(&report, "external_app");
1365
1366        assert_eq!(report.profile, AdoptionProfileV1::Partial);
1367        assert_eq!(report.summary.managed_configured_roles, 2);
1368        assert_eq!(report.summary.declared_only_roles, 1);
1369        assert_eq!(report.summary.attached_unobserved_roles, 0);
1370        assert_eq!(report.summary.observed_only_canisters, 1);
1371        assert_eq!(api.observation_state, AdoptionObservationStateV1::Observed);
1372        assert!(
1373            api.classifications
1374                .contains(&AdoptionClassificationV1::Managed)
1375        );
1376        assert!(
1377            store
1378                .classifications
1379                .contains(&AdoptionClassificationV1::DeclaredOnly)
1380        );
1381        assert_eq!(
1382            external_app.authority_state,
1383            AdoptionAuthorityStateV1::UserControlled
1384        );
1385    }
1386
1387    #[test]
1388    fn external_controller_fixture_reports_external_action_boundary() {
1389        let inventory = inventory(vec![observed_canister(
1390            "bbbbb-bb",
1391            Some("api"),
1392            CanisterControlClassV1::JointlyControlled,
1393            Some("api-hash"),
1394        )]);
1395        let report = report(CONFIG, Some(&inventory), Vec::new());
1396        let api = role(&report, "api");
1397
1398        assert!(
1399            api.classifications
1400                .contains(&AdoptionClassificationV1::Managed)
1401        );
1402        assert!(
1403            api.classifications
1404                .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1405        );
1406        assert_eq!(api.authority_state, AdoptionAuthorityStateV1::External);
1407        assert_eq!(report.summary.external_controller_required, 1);
1408        assert!(
1409            report
1410                .blocked_actions
1411                .contains(&"controller changes".to_string())
1412        );
1413    }
1414
1415    #[test]
1416    fn observed_only_fixture_without_role_stays_unmatched() {
1417        let inventory = inventory(vec![observed_canister(
1418            "zzzzz-zz",
1419            None,
1420            CanisterControlClassV1::UnknownUnsafe,
1421            Some("unknown-hash"),
1422        )]);
1423        let report = report(BROWNFIELD_CONFIG, Some(&inventory), Vec::new());
1424        let observed = report
1425            .observed_canisters
1426            .iter()
1427            .find(|finding| finding.canister_id == "zzzzz-zz")
1428            .expect("observed-only canister finding");
1429
1430        assert_eq!(report.summary.observed_only_canisters, 1);
1431        assert_eq!(observed.matched_role, None);
1432        assert_eq!(observed.confidence, AdoptionMatchConfidenceV1::None);
1433        assert!(
1434            observed
1435                .classifications
1436                .contains(&AdoptionClassificationV1::ObservedOnly)
1437        );
1438        assert!(
1439            observed.recommendations.is_empty(),
1440            "name-free observations must not invent declaration actions"
1441        );
1442    }
1443
1444    #[test]
1445    fn declared_only_fixture_reports_compile_only_role() {
1446        let report = report_with_profile(
1447            AdoptionProfileV1::Partial,
1448            CONFIG,
1449            None,
1450            matching_metadata(),
1451        );
1452        let store = role(&report, "store");
1453
1454        assert_eq!(store.package_state, AdoptionPackageStateV1::Matches);
1455        assert_eq!(store.topology_state, AdoptionTopologyStateV1::Unattached);
1456        assert!(
1457            store
1458                .classifications
1459                .contains(&AdoptionClassificationV1::DeclaredOnly)
1460        );
1461        assert!(
1462            store
1463                .recommendations
1464                .iter()
1465                .any(|recommendation| recommendation.kind == "attach_role_later"
1466                    && recommendation.suggested_action_effect
1467                        == AdoptionSuggestedActionEffectV1::MutatesState
1468                    && recommendation.suggested_action_support
1469                        == AdoptionSuggestedActionSupportV1::UnsupportedByAdoption)
1470        );
1471    }
1472
1473    #[test]
1474    fn standalone_fixture_keeps_compile_only_role_unattached() {
1475        let report = report_with_profile(
1476            AdoptionProfileV1::Standalone,
1477            STANDALONE_CONFIG,
1478            None,
1479            vec![AdoptionPackageMetadataV1 {
1480                package: "worker".to_string(),
1481                fleet: Some("demo".to_string()),
1482                role: Some("worker".to_string()),
1483            }],
1484        );
1485        let worker = role(&report, "worker");
1486
1487        assert_eq!(report.profile, AdoptionProfileV1::Standalone);
1488        assert_eq!(report.summary.managed_configured_roles, 0);
1489        assert_eq!(report.summary.declared_only_roles, 1);
1490        assert_eq!(report.summary.attached_unobserved_roles, 0);
1491        assert_eq!(worker.package_state, AdoptionPackageStateV1::Matches);
1492        assert_eq!(worker.topology_state, AdoptionTopologyStateV1::Unattached);
1493        assert_eq!(
1494            worker.observation_state,
1495            AdoptionObservationStateV1::Unobserved
1496        );
1497        assert!(
1498            worker
1499                .classifications
1500                .contains(&AdoptionClassificationV1::DeclaredOnly)
1501        );
1502        assert!(
1503            worker
1504                .evidence
1505                .iter()
1506                .any(|evidence| evidence == "no topology attachment exists")
1507        );
1508        assert!(
1509            report
1510                .blocked_actions
1511                .contains(&"topology attachment".to_string())
1512        );
1513    }
1514
1515    #[test]
1516    fn leaf_only_fixture_does_not_recommend_authority_hub_adoption() {
1517        let inventory = inventory(vec![
1518            observed_canister(
1519                "aaaaa-aa",
1520                Some("governance"),
1521                CanisterControlClassV1::UserControlled,
1522                Some("governance-hash"),
1523            ),
1524            observed_canister(
1525                "bbbbb-bb",
1526                Some("app"),
1527                CanisterControlClassV1::DeploymentControlled,
1528                Some("app-hash"),
1529            ),
1530        ]);
1531        let report = report_with_profile(
1532            AdoptionProfileV1::LeafOnly,
1533            LEAF_ONLY_CONFIG,
1534            Some(&inventory),
1535            Vec::new(),
1536        );
1537        let app = role(&report, "app");
1538        let governance = role(&report, "governance");
1539        let governance_observation = report
1540            .observed_canisters
1541            .iter()
1542            .find(|finding| finding.matched_role.as_deref() == Some("governance"))
1543            .expect("governance observation");
1544
1545        assert_eq!(report.profile, AdoptionProfileV1::LeafOnly);
1546        assert!(
1547            app.classifications
1548                .contains(&AdoptionClassificationV1::Managed)
1549        );
1550        assert_eq!(app.observation_state, AdoptionObservationStateV1::Observed);
1551        assert!(
1552            governance
1553                .classifications
1554                .contains(&AdoptionClassificationV1::ObservedOnly)
1555        );
1556        assert!(
1557            governance
1558                .classifications
1559                .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1560        );
1561        assert!(governance.recommendations.is_empty());
1562        assert!(
1563            governance
1564                .warnings
1565                .iter()
1566                .any(|warning| warning.contains("leaf-only profile"))
1567        );
1568        assert!(governance_observation.recommendations.is_empty());
1569        assert!(
1570            governance_observation
1571                .warnings
1572                .iter()
1573                .any(|warning| warning.contains("leaf-only profile"))
1574        );
1575        assert!(
1576            !report
1577                .recommendations
1578                .iter()
1579                .any(|recommendation| recommendation.suggested_action.as_deref()
1580                    == Some("canic fleet role declare demo governance --package <path>"))
1581        );
1582    }
1583
1584    #[test]
1585    fn adoption_report_reports_attached_unobserved_without_teardown_inference() {
1586        let report = report(CONFIG, None, Vec::new());
1587        let api = role(&report, "api");
1588
1589        assert!(
1590            api.classifications
1591                .contains(&AdoptionClassificationV1::Managed)
1592        );
1593        assert!(
1594            api.classifications
1595                .contains(&AdoptionClassificationV1::AttachedUnobserved)
1596        );
1597        assert_eq!(
1598            api.observation_state,
1599            AdoptionObservationStateV1::Unobserved
1600        );
1601        assert!(
1602            api.warnings
1603                .iter()
1604                .any(|warning| warning.contains("does not confirm"))
1605        );
1606    }
1607
1608    #[test]
1609    fn adoption_report_preserves_unresolved_evidence_gaps() {
1610        let mut inventory = inventory(Vec::new());
1611        inventory
1612            .unresolved_observations
1613            .push(DeploymentObservationGapV1 {
1614                key: "canister-status:api".to_string(),
1615                description: "status query failed".to_string(),
1616            });
1617        let mut manifest = external_api_artifact_manifest();
1618        manifest
1619            .unresolved_artifacts
1620            .push(DeploymentObservationGapV1 {
1621                key: "artifact:api".to_string(),
1622                description: "artifact file missing".to_string(),
1623            });
1624
1625        let report = adoption_report_from_config_source(AdoptionReportRequest {
1626            report_id: "adoption-1",
1627            generated_at: "2026-05-30T00:00:00Z",
1628            profile: AdoptionProfileV1::Partial,
1629            config_source: CONFIG,
1630            inventory: Some(&inventory),
1631            artifact_manifest: Some(&manifest),
1632            package_metadata: Vec::new(),
1633        })
1634        .expect("adoption report");
1635
1636        assert!(report.inputs.missing_or_stale_evidence.iter().any(|evidence| {
1637            evidence == "unresolved inventory observation canister-status:api: status query failed"
1638        }));
1639        assert!(
1640            report
1641                .inputs
1642                .missing_or_stale_evidence
1643                .iter()
1644                .any(|evidence| {
1645                    evidence == "unresolved artifact evidence artifact:api: artifact file missing"
1646                })
1647        );
1648    }
1649
1650    #[test]
1651    fn adoption_report_classifies_observed_only_user_controlled_canister() {
1652        let inventory = inventory(vec![observed_canister(
1653            "aaaaa-aa",
1654            Some("external_app"),
1655            CanisterControlClassV1::UserControlled,
1656            Some("external_app-hash"),
1657        )]);
1658        let report = report(CONFIG, Some(&inventory), Vec::new());
1659        let external_app = role(&report, "external_app");
1660
1661        assert_eq!(
1662            external_app.declaration_state,
1663            AdoptionDeclarationStateV1::Undeclared
1664        );
1665        assert!(
1666            external_app
1667                .classifications
1668                .contains(&AdoptionClassificationV1::ObservedOnly)
1669        );
1670        assert!(
1671            external_app
1672                .classifications
1673                .contains(&AdoptionClassificationV1::UserControlled)
1674        );
1675        assert!(
1676            external_app
1677                .classifications
1678                .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1679        );
1680        assert_eq!(report.summary.observed_only_canisters, 1);
1681        assert_eq!(report.summary.user_controlled_canisters, 1);
1682        assert!(
1683            report
1684                .recommendations
1685                .iter()
1686                .any(
1687                    |recommendation| recommendation.kind == "review_authority_before_declaration"
1688                        && recommendation.suggested_action.is_none()
1689                        && recommendation.suggested_action_effect
1690                            == AdoptionSuggestedActionEffectV1::ReadOnly
1691                        && recommendation.suggested_action_support
1692                            == AdoptionSuggestedActionSupportV1::SupportedByAdoption
1693                )
1694        );
1695        assert!(
1696            report
1697                .recommendations
1698                .iter()
1699                .all(|recommendation| recommendation.kind != "declare_role")
1700        );
1701    }
1702
1703    #[test]
1704    fn adoption_report_recommends_declaration_only_for_canic_authorized_observed_role() {
1705        let inventory = inventory(vec![observed_canister(
1706            "aaaaa-aa",
1707            Some("candidate"),
1708            CanisterControlClassV1::DeploymentControlled,
1709            Some("candidate-hash"),
1710        )]);
1711        let report = report(BROWNFIELD_CONFIG, Some(&inventory), Vec::new());
1712        let candidate = role(&report, "candidate");
1713
1714        assert_eq!(
1715            candidate.authority_state,
1716            AdoptionAuthorityStateV1::CanicAuthorized
1717        );
1718        assert!(
1719            report
1720                .recommendations
1721                .iter()
1722                .any(|recommendation| recommendation.kind == "declare_role"
1723                    && recommendation.suggested_action.as_deref()
1724                        == Some("canic fleet role declare demo candidate --package <path>")
1725                    && recommendation.suggested_action_effect
1726                        == AdoptionSuggestedActionEffectV1::MutatesState
1727                    && recommendation.suggested_action_support
1728                        == AdoptionSuggestedActionSupportV1::UnsupportedByAdoption)
1729        );
1730    }
1731
1732    #[test]
1733    fn adoption_report_authority_gates_observed_only_declaration_recommendations() {
1734        for (
1735            control_class,
1736            expected_authority,
1737            expected_recommendation_kind,
1738            expected_suggested_action,
1739        ) in [
1740            (
1741                CanisterControlClassV1::DeploymentControlled,
1742                AdoptionAuthorityStateV1::CanicAuthorized,
1743                "declare_role",
1744                Some("canic fleet role declare demo candidate --package <path>"),
1745            ),
1746            (
1747                CanisterControlClassV1::UserControlled,
1748                AdoptionAuthorityStateV1::UserControlled,
1749                "review_authority_before_declaration",
1750                None,
1751            ),
1752            (
1753                CanisterControlClassV1::ExternallyImported,
1754                AdoptionAuthorityStateV1::External,
1755                "review_authority_before_declaration",
1756                None,
1757            ),
1758            (
1759                CanisterControlClassV1::UnknownUnsafe,
1760                AdoptionAuthorityStateV1::Unknown,
1761                "review_authority_before_declaration",
1762                None,
1763            ),
1764        ] {
1765            let inventory = inventory(vec![observed_canister(
1766                "aaaaa-aa",
1767                Some("candidate"),
1768                control_class,
1769                Some("candidate-hash"),
1770            )]);
1771            let report = report(BROWNFIELD_CONFIG, Some(&inventory), Vec::new());
1772            let candidate = role(&report, "candidate");
1773            let recommendation = candidate
1774                .recommendations
1775                .first()
1776                .expect("authority-gated recommendation");
1777
1778            assert_eq!(candidate.authority_state, expected_authority);
1779            assert_eq!(recommendation.kind, expected_recommendation_kind);
1780            assert_eq!(
1781                recommendation.suggested_action.as_deref(),
1782                expected_suggested_action
1783            );
1784            if expected_authority == AdoptionAuthorityStateV1::CanicAuthorized {
1785                assert_eq!(
1786                    recommendation.suggested_action_availability,
1787                    AdoptionSuggestedActionAvailabilityV1::BlockedIn0500
1788                );
1789                assert_eq!(
1790                    recommendation.suggested_action_support,
1791                    AdoptionSuggestedActionSupportV1::UnsupportedByAdoption
1792                );
1793            } else {
1794                assert!(
1795                    candidate
1796                        .recommendations
1797                        .iter()
1798                        .all(|recommendation| recommendation.kind != "declare_role")
1799                );
1800            }
1801        }
1802    }
1803
1804    #[test]
1805    fn adoption_report_keeps_managed_separate_from_authority() {
1806        let inventory = inventory(vec![observed_canister(
1807            "aaaaa-aa",
1808            Some("api"),
1809            CanisterControlClassV1::UserControlled,
1810            Some("api-hash"),
1811        )]);
1812        let report = report(CONFIG, Some(&inventory), Vec::new());
1813        let api = role(&report, "api");
1814
1815        assert!(
1816            api.classifications
1817                .contains(&AdoptionClassificationV1::Managed)
1818        );
1819        assert!(
1820            api.classifications
1821                .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1822        );
1823        assert_eq!(
1824            api.authority_state,
1825            AdoptionAuthorityStateV1::UserControlled
1826        );
1827    }
1828
1829    #[test]
1830    fn adoption_report_marks_role_only_package_metadata_as_conflict() {
1831        let report = report(
1832            CONFIG,
1833            None,
1834            vec![AdoptionPackageMetadataV1 {
1835                package: "store".to_string(),
1836                fleet: None,
1837                role: Some("store".to_string()),
1838            }],
1839        );
1840        let store = role(&report, "store");
1841
1842        assert_eq!(store.package_state, AdoptionPackageStateV1::MissingFleet);
1843        assert!(
1844            store
1845                .classifications
1846                .contains(&AdoptionClassificationV1::EvidenceConflict)
1847        );
1848    }
1849
1850    #[test]
1851    fn adoption_report_marks_duplicate_observed_role_as_evidence_conflict() {
1852        let inventory = inventory(vec![
1853            observed_canister(
1854                "aaaaa-aa",
1855                Some("api"),
1856                CanisterControlClassV1::DeploymentControlled,
1857                Some("api-hash-a"),
1858            ),
1859            observed_canister(
1860                "bbbbb-bb",
1861                Some("api"),
1862                CanisterControlClassV1::DeploymentControlled,
1863                Some("api-hash-b"),
1864            ),
1865        ]);
1866        let report = report(CONFIG, Some(&inventory), Vec::new());
1867        let api = role(&report, "api");
1868
1869        assert_eq!(
1870            api.observation_state,
1871            AdoptionObservationStateV1::ConflictingMatch
1872        );
1873        assert!(
1874            api.classifications
1875                .contains(&AdoptionClassificationV1::EvidenceConflict)
1876        );
1877        assert_eq!(report.summary.evidence_conflicts, 1);
1878    }
1879
1880    #[test]
1881    fn adoption_report_marks_reverse_conflicting_artifact_evidence() {
1882        let mut inventory = inventory(Vec::new());
1883        inventory
1884            .observed_artifacts
1885            .push(observed_external_api_artifact());
1886        let manifest = RoleArtifactManifestV1 {
1887            schema_version: 1,
1888            manifest_id: "local-manifest-1".to_string(),
1889            network: "local".to_string(),
1890            artifact_root: None,
1891            role_artifacts: vec![RoleArtifactV1 {
1892                source: ArtifactSourceV1::LocalBuild,
1893                build_profile: "fast".to_string(),
1894                ..external_api_role_artifact()
1895            }],
1896            unresolved_artifacts: Vec::new(),
1897        };
1898
1899        let report = adoption_report_from_config_source(AdoptionReportRequest {
1900            report_id: "artifact-conflict-2",
1901            generated_at: "2026-05-30T00:00:00Z",
1902            profile: AdoptionProfileV1::HybridExternalWasm,
1903            config_source: CONFIG,
1904            inventory: Some(&inventory),
1905            artifact_manifest: Some(&manifest),
1906            package_metadata: Vec::new(),
1907        })
1908        .expect("adoption report");
1909        let api = role(&report, "api");
1910
1911        assert!(
1912            api.classifications
1913                .contains(&AdoptionClassificationV1::EvidenceConflict)
1914        );
1915        assert!(
1916            api.warnings
1917                .iter()
1918                .any(|warning| warning == "artifact evidence contains conflicting role facts")
1919        );
1920        assert_eq!(report.summary.evidence_conflicts, 1);
1921    }
1922
1923    #[test]
1924    fn adoption_report_marks_conflicting_artifact_evidence() {
1925        let mut inventory = inventory(Vec::new());
1926        inventory.observed_artifacts.push(ObservedArtifactV1 {
1927            role: "api".to_string(),
1928            artifact_path: "local/api.wasm.gz".to_string(),
1929            file_sha256: None,
1930            file_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
1931            payload_sha256: None,
1932            payload_size_bytes: None,
1933            source: ArtifactSourceV1::LocalBuild,
1934        });
1935        let manifest = external_api_artifact_manifest();
1936        let report = adoption_report_from_config_source(AdoptionReportRequest {
1937            report_id: "artifact-conflict-1",
1938            generated_at: "2026-05-30T00:00:00Z",
1939            profile: AdoptionProfileV1::HybridExternalWasm,
1940            config_source: CONFIG,
1941            inventory: Some(&inventory),
1942            artifact_manifest: Some(&manifest),
1943            package_metadata: Vec::new(),
1944        })
1945        .expect("adoption report");
1946        let api = role(&report, "api");
1947
1948        assert!(
1949            api.classifications
1950                .contains(&AdoptionClassificationV1::EvidenceConflict)
1951        );
1952        assert!(
1953            api.warnings
1954                .iter()
1955                .any(|warning| warning == "artifact evidence contains conflicting role facts")
1956        );
1957        assert_eq!(report.summary.evidence_conflicts, 1);
1958    }
1959
1960    #[test]
1961    fn adoption_report_classifies_pool_candidates_as_resources() {
1962        let mut inventory = inventory(Vec::new());
1963        inventory.observed_pool.push(ObservedPoolCanisterV1 {
1964            pool: "users".to_string(),
1965            canister_id: "ccccc-cc".to_string(),
1966            role: Some("user_shard".to_string()),
1967            control_class: CanisterControlClassV1::UnknownUnsafe,
1968        });
1969
1970        let report = report(CONFIG, Some(&inventory), Vec::new());
1971        let pool = report
1972            .observed_canisters
1973            .iter()
1974            .find(|finding| finding.canister_id == "ccccc-cc")
1975            .expect("pool candidate finding");
1976
1977        assert!(
1978            pool.classifications
1979                .contains(&AdoptionClassificationV1::ImportedPoolCandidate)
1980        );
1981        assert_eq!(pool.matched_role.as_deref(), Some("user_shard"));
1982    }
1983
1984    #[test]
1985    fn adoption_report_round_trips_through_json() {
1986        let manifest = RoleArtifactManifestV1 {
1987            schema_version: 1,
1988            manifest_id: "manifest-1".to_string(),
1989            network: "local".to_string(),
1990            artifact_root: None,
1991            role_artifacts: vec![RoleArtifactV1 {
1992                role: "api".to_string(),
1993                source: ArtifactSourceV1::LocalBuild,
1994                build_profile: "fast".to_string(),
1995                wasm_path: None,
1996                wasm_gz_path: None,
1997                wasm_gz_size_bytes: None,
1998                wasm_sha256: None,
1999                wasm_gz_sha256: None,
2000                wasm_gz_sha256_source: None,
2001                observed_wasm_gz_file_sha256: None,
2002                observed_wasm_gz_file_sha256_source: None,
2003                installed_module_hash: None,
2004                candid_path: None,
2005                candid_sha256: None,
2006                raw_config_sha256: None,
2007                canonical_embedded_config_sha256: None,
2008                embedded_topology_sha256: None,
2009                builder_version: None,
2010                rust_toolchain: None,
2011                package_version: None,
2012            }],
2013            unresolved_artifacts: Vec::new(),
2014        };
2015        let report = adoption_report_from_config_source(AdoptionReportRequest {
2016            report_id: "adoption-1",
2017            generated_at: "2026-05-30T00:00:00Z",
2018            profile: AdoptionProfileV1::Brownfield,
2019            config_source: CONFIG,
2020            inventory: None,
2021            artifact_manifest: Some(&manifest),
2022            package_metadata: Vec::new(),
2023        })
2024        .expect("adoption report");
2025
2026        let encoded = serde_json::to_string(&report).expect("encode report");
2027        let decoded = serde_json::from_str::<AdoptionReportV1>(&encoded).expect("decode report");
2028
2029        assert_eq!(decoded, report);
2030        assert_eq!(
2031            role(&decoded, "api").artifact_state,
2032            AdoptionArtifactStateV1::CanicBuilt
2033        );
2034    }
2035
2036    #[test]
2037    fn hybrid_external_wasm_fixture_reports_hashes_without_import() {
2038        let mut inventory = inventory(vec![observed_canister(
2039            "bbbbb-bb",
2040            Some("api"),
2041            CanisterControlClassV1::DeploymentControlled,
2042            Some("api-module-hash"),
2043        )]);
2044        inventory
2045            .observed_artifacts
2046            .push(observed_external_api_artifact());
2047        let manifest = external_api_artifact_manifest();
2048
2049        let report = adoption_report_from_config_source(AdoptionReportRequest {
2050            report_id: "hybrid-1",
2051            generated_at: "2026-05-30T00:00:00Z",
2052            profile: AdoptionProfileV1::HybridExternalWasm,
2053            config_source: CONFIG,
2054            inventory: Some(&inventory),
2055            artifact_manifest: Some(&manifest),
2056            package_metadata: Vec::new(),
2057        })
2058        .expect("adoption report");
2059        let api = role(&report, "api");
2060        let observed_api = report
2061            .observed_canisters
2062            .iter()
2063            .find(|finding| finding.matched_role.as_deref() == Some("api"))
2064            .expect("api observation");
2065
2066        assert_eq!(api.artifact_state, AdoptionArtifactStateV1::ExternalWasm);
2067        assert!(
2068            api.evidence
2069                .iter()
2070                .any(|evidence| evidence == "observed canister module_hash=api-module-hash")
2071        );
2072        assert!(
2073            api.evidence
2074                .iter()
2075                .any(|evidence| evidence == "artifact manifest source=external")
2076        );
2077        assert!(
2078            api.evidence.iter().any(|evidence| evidence
2079                == "artifact manifest installed_module_hash=api-installed-module")
2080        );
2081        assert!(
2082            api.evidence
2083                .iter()
2084                .any(|evidence| evidence == "observed artifact file_sha256=api-file-sha")
2085        );
2086        assert!(
2087            api.warnings
2088                .iter()
2089                .any(|warning| warning.contains("artifact registry import is outside"))
2090        );
2091        assert_eq!(
2092            observed_api.wasm_evidence.as_deref(),
2093            Some("module_hash=api-module-hash")
2094        );
2095        assert!(
2096            report
2097                .blocked_actions
2098                .contains(&"artifact registry import".to_string())
2099        );
2100        assert!(
2101            report
2102                .recommendations
2103                .iter()
2104                .all(|recommendation| !recommendation.kind.contains("artifact"))
2105        );
2106    }
2107
2108    fn report(
2109        config_source: &str,
2110        inventory: Option<&DeploymentInventoryV1>,
2111        package_metadata: Vec<AdoptionPackageMetadataV1>,
2112    ) -> AdoptionReportV1 {
2113        report_with_profile(
2114            AdoptionProfileV1::Brownfield,
2115            config_source,
2116            inventory,
2117            package_metadata,
2118        )
2119    }
2120
2121    fn report_with_profile(
2122        profile: AdoptionProfileV1,
2123        config_source: &str,
2124        inventory: Option<&DeploymentInventoryV1>,
2125        package_metadata: Vec<AdoptionPackageMetadataV1>,
2126    ) -> AdoptionReportV1 {
2127        adoption_report_from_config_source(AdoptionReportRequest {
2128            report_id: "adoption-1",
2129            generated_at: "2026-05-30T00:00:00Z",
2130            profile,
2131            config_source,
2132            inventory,
2133            artifact_manifest: None,
2134            package_metadata,
2135        })
2136        .expect("adoption report")
2137    }
2138
2139    fn matching_metadata() -> Vec<AdoptionPackageMetadataV1> {
2140        ["root", "api", "store"]
2141            .into_iter()
2142            .map(|package| AdoptionPackageMetadataV1 {
2143                package: package.to_string(),
2144                fleet: Some("demo".to_string()),
2145                role: Some(package.to_string()),
2146            })
2147            .collect()
2148    }
2149
2150    fn external_api_artifact_manifest() -> RoleArtifactManifestV1 {
2151        RoleArtifactManifestV1 {
2152            schema_version: 1,
2153            manifest_id: "external-manifest-1".to_string(),
2154            network: "local".to_string(),
2155            artifact_root: None,
2156            role_artifacts: vec![external_api_role_artifact()],
2157            unresolved_artifacts: Vec::new(),
2158        }
2159    }
2160
2161    fn external_api_role_artifact() -> RoleArtifactV1 {
2162        RoleArtifactV1 {
2163            role: "api".to_string(),
2164            source: ArtifactSourceV1::External,
2165            build_profile: "external".to_string(),
2166            wasm_path: Some("external/api.wasm".to_string()),
2167            wasm_gz_path: Some("external/api.wasm.gz".to_string()),
2168            wasm_gz_size_bytes: Some(42),
2169            wasm_sha256: Some("api-wasm-sha".to_string()),
2170            wasm_gz_sha256: Some("api-wasm-gz-sha".to_string()),
2171            wasm_gz_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
2172            observed_wasm_gz_file_sha256: Some("api-file-sha".to_string()),
2173            observed_wasm_gz_file_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
2174            installed_module_hash: Some("api-installed-module".to_string()),
2175            candid_path: None,
2176            candid_sha256: None,
2177            raw_config_sha256: None,
2178            canonical_embedded_config_sha256: None,
2179            embedded_topology_sha256: None,
2180            builder_version: None,
2181            rust_toolchain: None,
2182            package_version: None,
2183        }
2184    }
2185
2186    fn observed_external_api_artifact() -> ObservedArtifactV1 {
2187        ObservedArtifactV1 {
2188            role: "api".to_string(),
2189            artifact_path: "external/api.wasm.gz".to_string(),
2190            file_sha256: Some("api-file-sha".to_string()),
2191            file_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
2192            payload_sha256: Some("api-payload-sha".to_string()),
2193            payload_size_bytes: Some(42),
2194            source: ArtifactSourceV1::External,
2195        }
2196    }
2197
2198    fn role<'a>(report: &'a AdoptionReportV1, role: &str) -> &'a AdoptionRoleFindingV1 {
2199        report
2200            .role_findings
2201            .iter()
2202            .find(|finding| finding.role == role)
2203            .expect("role finding")
2204    }
2205
2206    fn inventory(observed_canisters: Vec<ObservedCanisterV1>) -> DeploymentInventoryV1 {
2207        DeploymentInventoryV1 {
2208            schema_version: 1,
2209            inventory_id: "inventory-1".to_string(),
2210            observed_at: "2026-05-30T00:00:00Z".to_string(),
2211            observed_identity: None,
2212            observed_root: Some(DeploymentRootObservationV1 {
2213                deployment_name: "demo-dev".to_string(),
2214                network: "local".to_string(),
2215                fleet_template: "demo".to_string(),
2216                root_principal: "aaaaa-aa".to_string(),
2217                observed_canister_id: "aaaaa-aa".to_string(),
2218                observation_source: DeploymentRootObservationSourceV1::LocalDeploymentState,
2219                control_class: CanisterControlClassV1::DeploymentControlled,
2220                controllers: vec!["aaaaa-aa".to_string()],
2221                module_hash: None,
2222                status: Some("running".to_string()),
2223                role_assignment_source: Some("local-state".to_string()),
2224            }),
2225            local_config: LocalDeploymentConfigV1 {
2226                config_path: Some("fleets/demo/canic.toml".to_string()),
2227                raw_config_sha256: None,
2228                canonical_embedded_config_sha256: None,
2229            },
2230            observed_canisters,
2231            observed_pool: Vec::new(),
2232            observed_artifacts: vec![ObservedArtifactV1 {
2233                role: "external_app".to_string(),
2234                artifact_path: "observed:external_app".to_string(),
2235                file_sha256: None,
2236                file_sha256_source: Some(ArtifactDigestSourceV1::InstalledModuleHash),
2237                payload_sha256: None,
2238                payload_size_bytes: None,
2239                source: ArtifactSourceV1::External,
2240            }],
2241            observed_verifier_readiness: VerifierReadinessObservationV1 {
2242                status: ObservationStatusV1::NotObserved,
2243                role_epochs: Vec::new(),
2244            },
2245            unresolved_observations: Vec::new(),
2246        }
2247    }
2248
2249    fn observed_canister(
2250        canister_id: &str,
2251        role: Option<&str>,
2252        control_class: CanisterControlClassV1,
2253        module_hash: Option<&str>,
2254    ) -> ObservedCanisterV1 {
2255        ObservedCanisterV1 {
2256            canister_id: canister_id.to_string(),
2257            role: role.map(str::to_string),
2258            control_class,
2259            controllers: vec!["controller".to_string()],
2260            module_hash: module_hash.map(str::to_string),
2261            status: Some("running".to_string()),
2262            root_trust_anchor: Some("root".to_string()),
2263            canonical_embedded_config_digest: None,
2264            role_assignment_source: role.map(|_| "explicit-test-evidence".to_string()),
2265        }
2266    }
2267}