Skip to main content

heddle_core/
verify.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Repository verification facade.
3
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    path::{Path, PathBuf},
7    time::Instant,
8};
9
10use ::objects::{HeddleError, error::Result, worktree::WorktreeStatus};
11use repo::{Repository, Thread, ThreadManager, describe_thread_advice, refresh_thread_freshness};
12use schemars::JsonSchema;
13use serde::{Serialize, Serializer};
14use sley::{Repository as SleyRepository, ShortStatusOptions, StatusUntrackedMode, StreamControl};
15
16use crate::{
17    ExecutionContext, HeddleReport, MachineOutputKind, OnboardingFacts, OutputDiscriminator,
18    ReportContract, plan_repository_onboarding, schema_for_report,
19    source_authority::{SourceAction, SourceAuthorityActions},
20    status::{
21        RepositoryVerificationHealth, build_repository_verification_health_with_worktree_status,
22        default_remote_name, git_default_remote_name_from_repo,
23        next_action::remote_tracking_status,
24    },
25};
26
27#[derive(Clone)]
28pub struct VerifyOptions {
29    pub start_path: Option<PathBuf>,
30    pub machine_contract_input: MachineContractInput,
31    pub action_audience: ActionAudience,
32}
33
34impl VerifyOptions {
35    pub fn new() -> Self {
36        Self {
37            start_path: None,
38            machine_contract_input: MachineContractInput::default(),
39            action_audience: ActionAudience::Human,
40        }
41    }
42
43    pub fn with_start_path(mut self, start_path: impl Into<PathBuf>) -> Self {
44        self.start_path = Some(start_path.into());
45        self
46    }
47
48    pub fn with_machine_contract_input(mut self, input: MachineContractInput) -> Self {
49        self.machine_contract_input = input;
50        self
51    }
52
53    pub fn with_action_audience(mut self, audience: ActionAudience) -> Self {
54        self.action_audience = audience;
55        self
56    }
57}
58
59impl Default for VerifyOptions {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65#[derive(Debug, Clone, Copy, Serialize, JsonSchema, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum ActionAudience {
68    Human,
69    Agent,
70    Script,
71}
72
73#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
74pub struct MachineContractInput {
75    pub coverage: MachineContractCoverage,
76}
77
78impl MachineContractInput {
79    pub fn from_coverage(coverage: MachineContractCoverage) -> Self {
80        Self { coverage }
81    }
82}
83
84impl Default for MachineContractInput {
85    fn default() -> Self {
86        Self {
87            coverage: MachineContractCoverage::not_checked(),
88        }
89    }
90}
91
92#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
93pub struct VerifyReport {
94    pub output_kind: &'static str,
95    pub clean: bool,
96    pub repository_label: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub repository_context: Option<RepositoryContextInfo>,
99    #[serde(rename = "verification")]
100    pub trust: RepositoryVerificationState,
101    #[serde(skip)]
102    #[schemars(skip)]
103    pub profile: VerifyProfile,
104}
105
106impl VerifyReport {
107    pub const CONTRACT: ReportContract = ReportContract {
108        schema_name: "verify",
109        machine_output_kind: MachineOutputKind::Json,
110        output_discriminator: Some(OutputDiscriminator {
111            field: "output_kind",
112            value: "verify",
113        }),
114        schema: schema_for_report::<VerifyReport>,
115    };
116}
117
118impl HeddleReport for VerifyReport {
119    const CONTRACT: ReportContract = VerifyReport::CONTRACT;
120}
121
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
123pub struct VerifyProfile {
124    pub plain_git_probe_ms: u128,
125    pub repo_open_ms: u128,
126    pub verification_ms: u128,
127}
128
129#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
130pub struct RepositoryContextInfo {
131    pub kind: String,
132    pub parent_repository: Option<String>,
133    pub target_thread: Option<String>,
134    pub parent_thread: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
138pub struct RepositoryPresentation {
139    pub label: String,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub context: Option<RepositoryContextInfo>,
142}
143
144#[derive(Debug, Serialize, JsonSchema)]
145pub struct PlainGitVerifyProbe {
146    #[schemars(with = "String")]
147    pub root: PathBuf,
148    pub git_branch: Option<String>,
149    #[serde(skip)]
150    #[schemars(skip)]
151    pub changes: WorktreeStatus,
152    /// Canonical public field is `verification` (alpha: no dual-emit of `trust`).
153    #[serde(rename = "verification")]
154    pub trust: RepositoryVerificationState,
155}
156
157#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
158pub struct ActionTemplate {
159    pub action: String,
160    pub argv_template: Vec<String>,
161    pub required_inputs: Vec<String>,
162    /// Whether an agent may replace placeholders in `argv_template`.
163    ///
164    /// When `agent_may_fill` is false, treat `action` and `argv_template` as
165    /// display-only: do not substitute `<name>`/`<url>` placeholders. Surface
166    /// the template to a human or discard it. Substituting and running it will
167    /// pass literal `<name>` to Heddle and fail.
168    pub agent_may_fill: bool,
169}
170
171#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
172pub struct RepositoryVerificationState {
173    #[serde(rename = "verified")]
174    pub verified: bool,
175    pub status: String,
176    pub repository_mode: String,
177    pub heddle_initialized: bool,
178    pub git_branch: Option<String>,
179    pub heddle_thread: Option<String>,
180    pub worktree_dirty: bool,
181    pub worktree_state: String,
182    pub import_state: String,
183    pub mapping_state: String,
184    pub remote_drift: String,
185    pub active_operation: Option<String>,
186    pub default_remote: Option<String>,
187    pub clone_verification: String,
188    pub machine_contract: String,
189    pub machine_contract_coverage: MachineContractCoverage,
190    pub workflow_status: String,
191    pub workflow_summary: String,
192    pub summary: String,
193    #[serde(serialize_with = "serialize_empty_action_as_null")]
194    #[schemars(with = "Option<String>")]
195    pub recommended_action: String,
196    pub recommended_action_template: Option<ActionTemplate>,
197    pub recovery_commands: Vec<String>,
198    pub recovery_action_templates: Vec<ActionTemplate>,
199    pub checks: Vec<VerificationCheck>,
200}
201
202pub fn serialize_empty_action_as_null<S>(
203    action: &String,
204    serializer: S,
205) -> std::result::Result<S::Ok, S::Error>
206where
207    S: Serializer,
208{
209    if action.is_empty() {
210        serializer.serialize_none()
211    } else {
212        serializer.serialize_some(action)
213    }
214}
215
216#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
217pub struct MachineContractCoverage {
218    pub status: String,
219    #[serde(rename = "verified_scope")]
220    pub verified_scope: String,
221    pub advanced_scope: String,
222    pub summary: String,
223    pub catalog_commands_total: usize,
224    pub catalog_mutating_commands_total: usize,
225    pub json_commands_total: usize,
226    pub json_mutating_commands_total: usize,
227    pub json_commands_with_schema: usize,
228    pub json_commands_with_accepted_opaque_schema: usize,
229    pub json_commands_without_schema: usize,
230    #[serde(rename = "verified_scope_json_commands_total")]
231    pub verified_scope_json_commands_total: usize,
232    #[serde(rename = "verified_scope_json_commands_with_schema")]
233    pub verified_scope_json_commands_with_schema: usize,
234    #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
235    pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
236    #[serde(rename = "verified_scope_json_commands_without_schema")]
237    pub verified_scope_json_commands_without_schema: usize,
238    pub advanced_scope_json_commands_total: usize,
239    pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
240    pub mutating_commands_total: usize,
241    pub mutating_commands_with_schema: usize,
242    pub mutating_commands_with_accepted_opaque_schema: usize,
243    pub mutating_commands_without_schema: usize,
244    #[serde(rename = "verified_scope_mutating_commands_total")]
245    pub verified_scope_mutating_commands_total: usize,
246    #[serde(rename = "verified_scope_mutating_commands_with_schema")]
247    pub verified_scope_mutating_commands_with_schema: usize,
248    #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
249    pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
250    #[serde(rename = "verified_scope_mutating_commands_without_schema")]
251    pub verified_scope_mutating_commands_without_schema: usize,
252    pub advanced_scope_mutating_commands_total: usize,
253    pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
254    pub schema_verbs_total: usize,
255    pub documented_schema_verbs_total: usize,
256    pub undocumented_schema_verbs_total: usize,
257    pub opaque_schema_verbs_total: usize,
258    pub accepted_opaque_schema_verbs_total: usize,
259    pub unaccepted_opaque_schema_verbs_total: usize,
260    pub supports_op_id_total: usize,
261    pub jsonl_commands_total: usize,
262    pub missing_schema_examples: Vec<String>,
263    pub missing_mutating_schema_examples: Vec<String>,
264    pub verified_scope_missing_schema_examples: Vec<String>,
265    pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
266    pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
267    pub accepted_opaque_schema_examples: Vec<String>,
268    pub unaccepted_opaque_schema_examples: Vec<String>,
269    pub undocumented_schema_examples: Vec<String>,
270}
271
272impl MachineContractCoverage {
273    pub fn not_checked() -> Self {
274        Self {
275            status: "not_checked".to_string(),
276            verified_scope: "not_checked".to_string(),
277            advanced_scope: "not_checked".to_string(),
278            summary: "Machine-contract proof was not supplied by this embedder".to_string(),
279            catalog_commands_total: 0,
280            catalog_mutating_commands_total: 0,
281            json_commands_total: 0,
282            json_mutating_commands_total: 0,
283            json_commands_with_schema: 0,
284            json_commands_with_accepted_opaque_schema: 0,
285            json_commands_without_schema: 0,
286            verified_scope_json_commands_total: 0,
287            verified_scope_json_commands_with_schema: 0,
288            verified_scope_json_commands_with_accepted_opaque_schema: 0,
289            verified_scope_json_commands_without_schema: 0,
290            advanced_scope_json_commands_total: 0,
291            advanced_scope_json_commands_with_accepted_opaque_schema: 0,
292            mutating_commands_total: 0,
293            mutating_commands_with_schema: 0,
294            mutating_commands_with_accepted_opaque_schema: 0,
295            mutating_commands_without_schema: 0,
296            verified_scope_mutating_commands_total: 0,
297            verified_scope_mutating_commands_with_schema: 0,
298            verified_scope_mutating_commands_with_accepted_opaque_schema: 0,
299            verified_scope_mutating_commands_without_schema: 0,
300            advanced_scope_mutating_commands_total: 0,
301            advanced_scope_mutating_commands_with_accepted_opaque_schema: 0,
302            schema_verbs_total: 0,
303            documented_schema_verbs_total: 0,
304            undocumented_schema_verbs_total: 0,
305            opaque_schema_verbs_total: 0,
306            accepted_opaque_schema_verbs_total: 0,
307            unaccepted_opaque_schema_verbs_total: 0,
308            supports_op_id_total: 0,
309            jsonl_commands_total: 0,
310            missing_schema_examples: Vec::new(),
311            missing_mutating_schema_examples: Vec::new(),
312            verified_scope_missing_schema_examples: Vec::new(),
313            verified_scope_accepted_opaque_schema_examples: Vec::new(),
314            advanced_scope_accepted_opaque_schema_examples: Vec::new(),
315            accepted_opaque_schema_examples: Vec::new(),
316            unaccepted_opaque_schema_examples: Vec::new(),
317            undocumented_schema_examples: Vec::new(),
318        }
319    }
320}
321
322#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
323pub struct VerificationCheck {
324    pub name: String,
325    pub status: String,
326    pub clean: bool,
327    pub summary: String,
328    pub recommended_action: Option<String>,
329    pub recommended_action_template: Option<ActionTemplate>,
330    pub recovery_commands: Vec<String>,
331    pub recovery_action_templates: Vec<ActionTemplate>,
332    #[serde(default)]
333    pub details: BTreeMap<String, String>,
334}
335
336pub fn build_plain_git_verification_probe(start: &Path) -> Result<Option<PlainGitVerifyProbe>> {
337    build_plain_git_verification_probe_with_machine_contract(
338        start,
339        &MachineContractInput::default(),
340    )
341}
342
343pub fn build_plain_git_verification_probe_with_machine_contract(
344    start: &Path,
345    machine_contract_input: &MachineContractInput,
346) -> Result<Option<PlainGitVerifyProbe>> {
347    let git_repo = match SleyRepository::discover(start) {
348        Ok(repo) => repo,
349        Err(_) => return Ok(None),
350    };
351    let Some(workdir) = git_repo.workdir() else {
352        return Ok(None);
353    };
354    let root = workdir
355        .canonicalize()
356        .unwrap_or_else(|_| workdir.to_path_buf());
357    if root.join(".heddle").exists() {
358        return Ok(None);
359    }
360
361    let git_branch = plain_git_current_branch(&git_repo);
362    let git_branches = plain_git_local_branches(&git_repo);
363    let git_tags = plain_git_local_tags(&git_repo);
364    let changes = plain_git_worktree_status(&git_repo)?;
365    let onboarding = plan_repository_onboarding(OnboardingFacts {
366        git_worktree: true,
367        git_has_commits: git_repo.head().ok().and_then(|head| head.oid).is_some(),
368        heddle_mode: None,
369    });
370
371    let default_remote = git_default_remote_name_from_repo(&git_repo);
372    let setup_action = onboarding
373        .recommended_command()
374        .expect("plain Git without Heddle metadata requires onboarding")
375        .to_string();
376    let recovery_commands = vec![setup_action.clone()];
377    let machine_contract_coverage = machine_contract_input.coverage.clone();
378    let mut details = BTreeMap::new();
379    details.insert("path".to_string(), root.display().to_string());
380    if let Some(branch) = &git_branch {
381        details.insert("git_branch".to_string(), branch.clone());
382    }
383    if let Some(remote) = &default_remote {
384        details.insert("default_remote".to_string(), remote.clone());
385    }
386    details.insert(
387        "git_branch_count".to_string(),
388        git_branches.len().to_string(),
389    );
390    details.insert("git_tag_count".to_string(), git_tags.len().to_string());
391    details.insert(
392        "onboarding_state".to_string(),
393        onboarding.state.as_str().to_string(),
394    );
395
396    let mut checks = vec![
397        VerificationCheck {
398            name: "Git".to_string(),
399            status: "present".to_string(),
400            clean: true,
401            summary: "plain Git repository found".to_string(),
402            recommended_action: None,
403            recommended_action_template: None,
404            recovery_commands: Vec::new(),
405            recovery_action_templates: Vec::new(),
406            details,
407        },
408        VerificationCheck {
409            name: "Heddle".to_string(),
410            status: "needs_init".to_string(),
411            clean: false,
412            summary: "Heddle data is not initialized".to_string(),
413            recommended_action: Some(setup_action.clone()),
414            recommended_action_template: action_template(&setup_action),
415            recovery_commands: recovery_commands.clone(),
416            recovery_action_templates: action_templates(&recovery_commands),
417            details: BTreeMap::new(),
418        },
419        VerificationCheck {
420            name: "Mapping".to_string(),
421            status: "git_backed".to_string(),
422            clean: true,
423            summary: onboarding.storage_summary().to_string(),
424            recommended_action: None,
425            recommended_action_template: None,
426            recovery_commands: Vec::new(),
427            recovery_action_templates: Vec::new(),
428            details: BTreeMap::new(),
429        },
430    ];
431    checks.push(verification_check(
432        "Worktree",
433        changes.is_clean(),
434        if changes.is_clean() {
435            "clean"
436        } else {
437            "dirty_worktree"
438        },
439        if changes.is_clean() {
440            "Git worktree is clean"
441        } else {
442            "Git worktree has uncommitted changes"
443        },
444        None,
445        Vec::new(),
446    ));
447    checks.push(verification_check(
448        "Remote",
449        false,
450        "unknown",
451        "remote drift is checked after Heddle initialization",
452        None,
453        Vec::new(),
454    ));
455    checks.push(verification_check(
456        "Operation",
457        true,
458        "clean",
459        "no Heddle operation in progress",
460        None,
461        Vec::new(),
462    ));
463    checks.push(verification_check(
464        "Workflow",
465        false,
466        "not_checked",
467        "workflow readiness is checked after Heddle initialization",
468        None,
469        Vec::new(),
470    ));
471    checks.push(machine_contract_verification_check(
472        &machine_contract_coverage,
473    ));
474    checks.push(verification_check(
475        "Clone",
476        true,
477        "not_applicable",
478        "clone verification is not applicable to this checkout",
479        None,
480        Vec::new(),
481    ));
482
483    let trust = RepositoryVerificationState {
484        verified: false,
485        status: "needs_init".to_string(),
486        repository_mode: "plain-git".to_string(),
487        heddle_initialized: false,
488        git_branch: git_branch.clone(),
489        heddle_thread: None,
490        worktree_dirty: !changes.is_clean(),
491        worktree_state: if changes.is_clean() { "clean" } else { "dirty" }.to_string(),
492        import_state: "git_backed".to_string(),
493        mapping_state: "git_backed".to_string(),
494        remote_drift: "unknown".to_string(),
495        active_operation: None,
496        default_remote,
497        clone_verification: "not_applicable".to_string(),
498        machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
499        machine_contract_coverage,
500        workflow_status: "not_checked".to_string(),
501        workflow_summary: "workflow readiness is checked after Heddle initialization".to_string(),
502        summary: "Git repository has not been initialized for Heddle".to_string(),
503        recommended_action: setup_action.clone(),
504        recommended_action_template: action_template(&setup_action),
505        recovery_commands: recovery_commands.clone(),
506        recovery_action_templates: action_templates(&recovery_commands),
507        checks,
508    };
509    Ok(Some(PlainGitVerifyProbe {
510        root,
511        git_branch,
512        changes,
513        trust,
514    }))
515}
516
517fn plain_git_current_branch(git_repo: &SleyRepository) -> Option<String> {
518    git_repo.head().ok()?.branch_name().map(str::to_string)
519}
520
521fn plain_git_local_branches(git_repo: &SleyRepository) -> Vec<String> {
522    let Ok(branches) = git_repo.references().list_refs() else {
523        return Vec::new();
524    };
525    let mut names = branches
526        .into_iter()
527        .filter_map(|branch| branch.name.strip_prefix("refs/heads/").map(str::to_string))
528        .filter(|branch| !branch.trim().is_empty())
529        .collect::<Vec<_>>();
530    names.sort();
531    names.dedup();
532    names
533}
534
535fn plain_git_local_tags(git_repo: &SleyRepository) -> Vec<String> {
536    let Ok(tags) = git_repo.references().list_refs() else {
537        return Vec::new();
538    };
539    let mut names = tags
540        .into_iter()
541        .filter_map(|tag| tag.name.strip_prefix("refs/tags/").map(str::to_string))
542        .filter(|tag| !tag.trim().is_empty())
543        .collect::<Vec<_>>();
544    names.sort();
545    names.dedup();
546    names
547}
548
549fn plain_git_worktree_status(git_repo: &SleyRepository) -> Result<WorktreeStatus> {
550    let mut added = BTreeSet::new();
551    let mut modified = BTreeSet::new();
552    let mut deleted = BTreeSet::new();
553    git_repo
554        .stream_short_status_with_options(
555            ShortStatusOptions {
556                untracked_mode: StatusUntrackedMode::All,
557                ..ShortStatusOptions::default()
558            },
559            |entry| {
560                let path = PathBuf::from(String::from_utf8_lossy(entry.path).into_owned());
561                if entry.index == b'?' && entry.worktree == b'?' {
562                    added.insert(path);
563                } else if entry.index == b'D' || entry.worktree == b'D' {
564                    deleted.insert(path);
565                } else if entry.index == b'A'
566                    || entry.index == b'R'
567                    || entry.index == b'C'
568                    || entry.head_oid.is_none()
569                {
570                    added.insert(path);
571                } else {
572                    modified.insert(path);
573                }
574                Ok(StreamControl::Continue)
575            },
576        )
577        .map_err(|error| HeddleError::Config(error.to_string()))?;
578
579    for path in &added {
580        modified.remove(path);
581    }
582    for path in &deleted {
583        modified.remove(path);
584    }
585
586    Ok(WorktreeStatus {
587        modified: modified.into_iter().collect(),
588        added: added.into_iter().collect(),
589        deleted: deleted.into_iter().collect(),
590    })
591}
592
593pub fn build_repository_verification_state(
594    repo: &Repository,
595) -> Result<RepositoryVerificationState> {
596    build_repository_verification_state_with_machine_contract(
597        repo,
598        &MachineContractInput::default(),
599    )
600}
601
602pub fn build_repository_verification_state_with_machine_contract(
603    repo: &Repository,
604    machine_contract_input: &MachineContractInput,
605) -> Result<RepositoryVerificationState> {
606    let worktree_status = if repo.capability() == repo::RepositoryCapability::GitOverlay {
607        repo.git_overlay_worktree_status()
608    } else {
609        native_worktree_status(repo)
610    };
611    let health = build_repository_verification_health_with_worktree_status(repo, &worktree_status);
612    Ok(
613        build_repository_verification_state_with_worktree_status_and_machine_contract(
614            repo,
615            health,
616            &worktree_status,
617            machine_contract_input,
618        ),
619    )
620}
621
622fn native_worktree_status(repo: &Repository) -> Result<Option<WorktreeStatus>> {
623    let Some(state) = repo.current_state()? else {
624        return Ok(Some(WorktreeStatus::default()));
625    };
626    let tree = repo.require_tree(&state.tree)?;
627    repo.compare_worktree_cached(&tree).map(Some)
628}
629
630pub fn build_repository_verification_state_with_worktree_status(
631    repo: &Repository,
632    health: RepositoryVerificationHealth,
633    worktree_status: &Result<Option<WorktreeStatus>>,
634) -> RepositoryVerificationState {
635    build_repository_verification_state_with_worktree_status_and_machine_contract(
636        repo,
637        health,
638        worktree_status,
639        &MachineContractInput::default(),
640    )
641}
642
643pub fn build_repository_verification_state_with_worktree_status_and_machine_contract(
644    repo: &Repository,
645    health: RepositoryVerificationHealth,
646    worktree_status: &Result<Option<WorktreeStatus>>,
647    machine_contract_input: &MachineContractInput,
648) -> RepositoryVerificationState {
649    let git_branch = repo.git_overlay_current_branch().ok().flatten();
650    let heddle_thread = repo.current_lane().ok().flatten();
651    let active_operation = repo.operation_status().ok().flatten().map(|operation| {
652        format!(
653            "{} {} ({})",
654            operation.scope, operation.kind, operation.state
655        )
656    });
657    let remote_drift = repo
658        .git_remote_tracking_status()
659        .ok()
660        .flatten()
661        .map(|remote| remote_tracking_status(&remote).to_string())
662        .unwrap_or_else(|| "clean".to_string());
663    let is_git_overlay = repo.capability() == repo::RepositoryCapability::GitOverlay;
664    let import_state = health
665        .checks
666        .iter()
667        .find(|check| check.name == "import" && check.status != "clean")
668        .or_else(|| health.checks.iter().find(|check| check.name == "import"))
669        .map(|check| check.status.clone())
670        .unwrap_or_else(|| {
671            if is_git_overlay {
672                "git_backed".to_string()
673            } else {
674                "clean".to_string()
675            }
676        });
677    let mapping_state = health
678        .checks
679        .iter()
680        .find(|check| {
681            matches!(check.name.as_str(), "head_mapping" | "tag_mapping")
682                && !verification_status_is_clean(&check.status)
683        })
684        .or_else(|| {
685            health
686                .checks
687                .iter()
688                .find(|check| check.name == "head_mapping")
689        })
690        .map(|check| check.status.clone())
691        .unwrap_or_else(|| {
692            if is_git_overlay {
693                "git_backed".to_string()
694            } else {
695                "clean".to_string()
696            }
697        });
698    let git_worktree_dirty = matches!(
699        worktree_status,
700        Ok(Some(status)) if !status.is_clean()
701    );
702    let worktree_dirty = git_worktree_dirty
703        || health.checks.iter().any(|check| {
704            matches!(check.name.as_str(), "worktree" | "heddle_worktree") && check.status != "clean"
705        });
706    let machine_contract_coverage = machine_contract_input.coverage.clone();
707    let machine_contract_clean = machine_contract_is_clean(&machine_contract_coverage);
708    let mut recovery_commands = health.recovery_commands.clone();
709    let remote_action = remote_sync_action(&health, repo.source_authority());
710    let (workflow_status, workflow_summary) = workflow_status(repo, heddle_thread.as_deref());
711    let workflow_action = if health.clean && workflow_status == "ready" {
712        workflow_primary_action(repo)
713    } else {
714        None
715    };
716    if health.clean && !machine_contract_clean {
717        recovery_commands.push("heddle doctor schemas --output json".to_string());
718    }
719    let recommended_action = if health.clean {
720        if !machine_contract_clean {
721            "heddle doctor schemas --output json".to_string()
722        } else {
723            workflow_action
724                .clone()
725                .or_else(|| remote_action.clone())
726                .unwrap_or_default()
727        }
728    } else {
729        recovery_commands.first().cloned().unwrap_or_default()
730    };
731    let checks = verification_checks_from_health(
732        &health,
733        &machine_contract_coverage,
734        is_git_overlay,
735        &workflow_status,
736        &workflow_summary,
737        workflow_action.as_deref(),
738        repo.source_authority(),
739    );
740    RepositoryVerificationState {
741        verified: health.clean && machine_contract_clean,
742        status: if health.clean && !machine_contract_clean {
743            "machine_contract_gaps".to_string()
744        } else {
745            health.status.clone()
746        },
747        repository_mode: repo.capability_label().to_string(),
748        heddle_initialized: true,
749        git_branch,
750        heddle_thread,
751        worktree_dirty,
752        worktree_state: if worktree_dirty { "dirty" } else { "clean" }.to_string(),
753        import_state,
754        mapping_state,
755        remote_drift,
756        active_operation,
757        default_remote: default_remote_name(repo),
758        clone_verification: if repo.capability() == repo::RepositoryCapability::GitOverlay {
759            if health.clean {
760                "verified"
761            } else if matches!(
762                health.status.as_str(),
763                "dirty_worktree" | "needs_checkpoint"
764            ) {
765                "not_checked"
766            } else {
767                "blocked"
768            }
769        } else {
770            "not_applicable"
771        }
772        .to_string(),
773        machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
774        machine_contract_coverage,
775        workflow_status,
776        workflow_summary,
777        summary: health.summary,
778        recommended_action: recommended_action.clone(),
779        recommended_action_template: action_template(&recommended_action),
780        recovery_commands: recovery_commands.clone(),
781        recovery_action_templates: action_templates(&recovery_commands),
782        checks,
783    }
784}
785
786fn verification_checks_from_health(
787    health: &RepositoryVerificationHealth,
788    coverage: &MachineContractCoverage,
789    is_git_overlay: bool,
790    workflow_status: &str,
791    workflow_summary: &str,
792    workflow_action: Option<&str>,
793    source_authority: repo::RepositorySourceAuthority,
794) -> Vec<VerificationCheck> {
795    let mut checks = vec![
796        git_verification_check(is_git_overlay),
797        verification_check(
798            "Heddle",
799            true,
800            "clean",
801            "Heddle data is initialized",
802            None,
803            Vec::new(),
804        ),
805        mapping_verification_check(health, is_git_overlay),
806        worktree_verification_check(health),
807        remote_verification_check(health, source_authority),
808        operation_verification_check(health),
809        workflow_verification_check(health, workflow_status, workflow_summary, workflow_action),
810    ];
811    checks.push(machine_contract_verification_check(coverage));
812    checks.push(clone_verification_check(health, is_git_overlay));
813    checks
814}
815
816fn machine_contract_verification_check(coverage: &MachineContractCoverage) -> VerificationCheck {
817    let mut details = BTreeMap::new();
818    details.insert("coverage_status".to_string(), coverage.status.clone());
819    details.insert("coverage_summary".to_string(), coverage.summary.clone());
820    details.insert(
821        "verified_scope".to_string(),
822        coverage.verified_scope.clone(),
823    );
824    details.insert(
825        "advanced_scope".to_string(),
826        coverage.advanced_scope.clone(),
827    );
828    details.insert(
829        "catalog_commands_total".to_string(),
830        coverage.catalog_commands_total.to_string(),
831    );
832    details.insert(
833        "json_commands_total".to_string(),
834        coverage.json_commands_total.to_string(),
835    );
836    details.insert(
837        "json_commands_with_schema".to_string(),
838        coverage.json_commands_with_schema.to_string(),
839    );
840    details.insert(
841        "json_commands_without_schema".to_string(),
842        coverage.json_commands_without_schema.to_string(),
843    );
844    details.insert(
845        "json_commands_with_accepted_opaque_schema".to_string(),
846        coverage
847            .json_commands_with_accepted_opaque_schema
848            .to_string(),
849    );
850    details.insert(
851        "verified_scope_json_commands_total".to_string(),
852        coverage.verified_scope_json_commands_total.to_string(),
853    );
854    let mut check = verification_check(
855        "Machine contract",
856        machine_contract_is_clean(coverage),
857        machine_contract_status(coverage),
858        &coverage.summary,
859        (!machine_contract_is_clean(coverage))
860            .then(|| "heddle doctor schemas --output json".to_string()),
861        if machine_contract_is_clean(coverage) {
862            Vec::new()
863        } else {
864            vec!["heddle doctor schemas --output json".to_string()]
865        },
866    );
867    check.details = details;
868    check
869}
870
871fn git_verification_check(is_git_overlay: bool) -> VerificationCheck {
872    if is_git_overlay {
873        verification_check(
874            "Git",
875            true,
876            "clean",
877            "Git overlay repository is present",
878            None,
879            Vec::new(),
880        )
881    } else {
882        verification_check(
883            "Git",
884            true,
885            "not_applicable",
886            "Heddle-native repository is running in non-overlay mode",
887            None,
888            Vec::new(),
889        )
890    }
891}
892
893fn mapping_verification_check(
894    health: &RepositoryVerificationHealth,
895    is_git_overlay: bool,
896) -> VerificationCheck {
897    if !is_git_overlay {
898        return verification_check(
899            "Mapping",
900            true,
901            "not_applicable",
902            "native Heddle refs do not require Git Projection Mapping",
903            None,
904            Vec::new(),
905        );
906    }
907    if let Some(check) = health
908        .checks
909        .iter()
910        .find(|check| check.name == "head_mapping" && !verification_status_is_clean(&check.status))
911    {
912        return verification_check_from_health("Mapping", check, health);
913    }
914    if let Some(check) = find_health_check(health, "import")
915        && check.status != "clean"
916    {
917        return verification_check_from_health("Mapping", check, health);
918    }
919    if let Some(check) = find_health_check(health, "tag_mapping")
920        && check.status != "clean"
921    {
922        return verification_check_from_health("Mapping", check, health);
923    }
924    if let Some(check) = find_health_check(health, "head_mapping") {
925        if check.status == "git_backed" && health.status == "dirty_worktree" {
926            return verification_check(
927                "Mapping",
928                true,
929                "clean",
930                "Git-backed branch mapping is not blocking verification",
931                None,
932                Vec::new(),
933            );
934        }
935        return verification_check_from_health("Mapping", check, health);
936    }
937    verification_check(
938        "Mapping",
939        true,
940        "clean",
941        "Git branch tips map to imported Heddle state",
942        None,
943        Vec::new(),
944    )
945}
946
947fn worktree_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
948    for name in ["worktree", "heddle_worktree"] {
949        if let Some(check) = find_health_check(health, name)
950            && check.status != "clean"
951        {
952            return verification_check_from_health("Worktree", check, health);
953        }
954    }
955    for name in ["worktree", "heddle_worktree"] {
956        if let Some(check) = find_health_check(health, name) {
957            return verification_check_from_health("Worktree", check, health);
958        }
959    }
960    if !health.clean {
961        return verification_check(
962            "Worktree",
963            false,
964            "not_checked",
965            "worktree agreement is checked after the primary verification blocker is resolved",
966            health.recovery_commands.first().cloned(),
967            health.recovery_commands.clone(),
968        );
969    }
970    verification_check(
971        "Worktree",
972        true,
973        "clean",
974        "worktree has no uncommitted Git/Heddle disagreement",
975        None,
976        Vec::new(),
977    )
978}
979
980fn remote_verification_check(
981    health: &RepositoryVerificationHealth,
982    source_authority: repo::RepositorySourceAuthority,
983) -> VerificationCheck {
984    if let Some(check) = find_health_check(health, "remote_tracking") {
985        if matches!(check.status.as_str(), "remote_ahead" | "remote_untracked") {
986            let mut remote_check = verification_check(
987                "Remote",
988                true,
989                &check.status,
990                &check.summary,
991                remote_sync_action(health, source_authority),
992                Vec::new(),
993            );
994            remote_check.details = check.details.clone();
995            return remote_check;
996        }
997        return verification_check_from_health("Remote", check, health);
998    }
999    verification_check(
1000        "Remote",
1001        true,
1002        "clean",
1003        "remote tracking has no blocking drift",
1004        None,
1005        Vec::new(),
1006    )
1007}
1008
1009fn operation_verification_check(health: &RepositoryVerificationHealth) -> VerificationCheck {
1010    if let Some(check) = find_health_check(health, "operation") {
1011        return verification_check_from_health("Operation", check, health);
1012    }
1013    verification_check(
1014        "Operation",
1015        true,
1016        "clean",
1017        "no Git or Heddle operation in progress",
1018        None,
1019        Vec::new(),
1020    )
1021}
1022
1023fn workflow_verification_check(
1024    health: &RepositoryVerificationHealth,
1025    workflow_status: &str,
1026    workflow_summary: &str,
1027    workflow_action: Option<&str>,
1028) -> VerificationCheck {
1029    if let Some(check) = find_health_check(health, "thread_integration_metadata")
1030        && check.status != "clean"
1031    {
1032        return verification_check_from_health("Workflow", check, health);
1033    }
1034    if !health.clean {
1035        return verification_check(
1036            "Workflow",
1037            false,
1038            "blocked",
1039            "workflow readiness is checked after the primary verification blocker is resolved",
1040            health.recovery_commands.first().cloned(),
1041            health.recovery_commands.clone(),
1042        );
1043    }
1044    // When ready work is actionable ("ready"), surface the concrete land
1045    // command on the Workflow check itself so agents inspecting the
1046    // verification proof get the same runnable next action the top-level
1047    // recommendation carries (dropped when the check moved cli->core, which
1048    // passed `None`).
1049    let recommended_action = (workflow_status == "ready")
1050        .then(|| workflow_action.map(str::to_string))
1051        .flatten();
1052    verification_check(
1053        "Workflow",
1054        true,
1055        workflow_status,
1056        workflow_summary,
1057        recommended_action,
1058        Vec::new(),
1059    )
1060}
1061
1062fn clone_verification_check(
1063    health: &RepositoryVerificationHealth,
1064    is_git_overlay: bool,
1065) -> VerificationCheck {
1066    if !is_git_overlay {
1067        return verification_check(
1068            "Clone",
1069            true,
1070            "not_applicable",
1071            "native Heddle state is the checkout authority",
1072            None,
1073            Vec::new(),
1074        );
1075    }
1076    if health.clean {
1077        return verification_check(
1078            "Clone",
1079            true,
1080            "verified",
1081            "Git checkout and Heddle mapping agree",
1082            None,
1083            Vec::new(),
1084        );
1085    }
1086    if matches!(
1087        health.status.as_str(),
1088        "dirty_worktree" | "needs_checkpoint"
1089    ) {
1090        return verification_check(
1091            "Clone",
1092            true,
1093            "not_checked",
1094            "clone verification waits for a clean worktree",
1095            None,
1096            Vec::new(),
1097        );
1098    }
1099    verification_check(
1100        "Clone",
1101        false,
1102        "blocked",
1103        "clone verification is blocked until verification checks agree",
1104        health.recovery_commands.first().cloned(),
1105        health.recovery_commands.clone(),
1106    )
1107}
1108
1109fn verification_check_from_health(
1110    name: &str,
1111    health_check: &crate::status::RepositoryVerificationCheck,
1112    health: &RepositoryVerificationHealth,
1113) -> VerificationCheck {
1114    let recommended_action = (!verification_status_is_clean(&health_check.status))
1115        .then(|| health.recovery_commands.first().cloned())
1116        .flatten();
1117    let recovery_commands = if recommended_action.is_some() {
1118        health.recovery_commands.clone()
1119    } else {
1120        Vec::new()
1121    };
1122    let mut check = verification_check(
1123        name,
1124        verification_status_is_clean(&health_check.status),
1125        &health_check.status,
1126        &health_check.summary,
1127        recommended_action,
1128        recovery_commands,
1129    );
1130    check.details = health_check.details.clone();
1131    check
1132}
1133
1134fn remote_sync_action(
1135    health: &RepositoryVerificationHealth,
1136    source_authority: repo::RepositorySourceAuthority,
1137) -> Option<String> {
1138    find_health_check(health, "remote_tracking").and_then(|check| {
1139        matches!(check.status.as_str(), "remote_ahead" | "remote_untracked")
1140            .then(|| SourceAuthorityActions::new(source_authority).display(SourceAction::Push))
1141    })
1142}
1143
1144fn find_health_check<'a>(
1145    health: &'a RepositoryVerificationHealth,
1146    name: &str,
1147) -> Option<&'a crate::status::RepositoryVerificationCheck> {
1148    health.checks.iter().find(|check| check.name == name)
1149}
1150
1151fn verification_status_is_clean(status: &str) -> bool {
1152    matches!(
1153        status,
1154        "clean"
1155            | "available"
1156            | "git_backed"
1157            | "not_applicable"
1158            | "verified"
1159            | "remote_ahead"
1160            | "remote_untracked"
1161    )
1162}
1163
1164fn workflow_status(repo: &Repository, current_thread: Option<&str>) -> (String, String) {
1165    let ready_threads = ThreadManager::new(repo.heddle_dir())
1166        .list()
1167        .unwrap_or_default()
1168        .into_iter()
1169        .filter(|thread| thread.state == repo::ThreadState::Ready)
1170        .collect::<Vec<_>>();
1171    if ready_threads.is_empty() {
1172        return (
1173            "clean".to_string(),
1174            "no ready thread actions require attention".to_string(),
1175        );
1176    }
1177    // A dedicated checkout can safely print a parent-repo land command for a
1178    // ready thread even when its recorded `target_thread` differs from the
1179    // checkout's current lane: the merge runs against the parent repo. The
1180    // MAIN checkout, by contrast, must be on the thread's recorded target
1181    // before the ready work becomes the primary next action. This mirrors
1182    // `workflow_primary_action` / the pre-facade `actionable_from_current_thread`
1183    // classification — without the dedicated-checkout allowance, `ready`/`status`
1184    // from an isolated checkout wrongly report `workflow_status = "clean"` for a
1185    // thread that is in fact ready to land.
1186    let opened_from_dedicated_checkout = repo
1187        .heddle_dir()
1188        .parent()
1189        .is_some_and(|main_root| main_root != repo.root());
1190    let all_target_another_thread = ready_threads.iter().all(|thread| {
1191        let actionable = thread
1192            .target_thread
1193            .as_deref()
1194            .map(|target| current_thread == Some(target) || opened_from_dedicated_checkout)
1195            .unwrap_or(true);
1196        !actionable
1197    });
1198    if all_target_another_thread {
1199        return (
1200            "clean".to_string(),
1201            "ready thread actions target another thread".to_string(),
1202        );
1203    }
1204    (
1205        "ready".to_string(),
1206        "ready thread actions are waiting to land".to_string(),
1207    )
1208}
1209
1210fn workflow_primary_action(repo: &Repository) -> Option<String> {
1211    let current_thread = repo.current_lane().ok().flatten();
1212    let opened_from_dedicated_checkout = repo
1213        .heddle_dir()
1214        .parent()
1215        .is_some_and(|main_root| main_root != repo.root());
1216    ThreadManager::new(repo.heddle_dir())
1217        .list()
1218        .ok()?
1219        .into_iter()
1220        .filter(|thread| thread.state == repo::ThreadState::Ready)
1221        .find_map(|mut thread| {
1222            let _ = refresh_thread_freshness(repo, &mut thread);
1223            let actionable = thread
1224                .target_thread
1225                .as_deref()
1226                .map(|target| {
1227                    current_thread.as_deref() == Some(target) || opened_from_dedicated_checkout
1228                })
1229                .unwrap_or(true);
1230            if !actionable {
1231                return None;
1232            }
1233            let advice = describe_thread_advice(&thread, false, 0, false);
1234            (!advice.recommended_action.trim().is_empty()).then_some(advice.recommended_action)
1235        })
1236}
1237
1238fn verification_check(
1239    name: &str,
1240    clean: bool,
1241    status: &str,
1242    summary: &str,
1243    recommended_action: Option<String>,
1244    recovery_commands: Vec<String>,
1245) -> VerificationCheck {
1246    VerificationCheck {
1247        name: name.to_string(),
1248        status: status.to_string(),
1249        clean,
1250        summary: summary.to_string(),
1251        recommended_action: recommended_action.clone(),
1252        recommended_action_template: recommended_action.as_deref().and_then(action_template),
1253        recovery_action_templates: action_templates(&recovery_commands),
1254        recovery_commands,
1255        details: BTreeMap::new(),
1256    }
1257}
1258
1259pub fn action_template(action: &str) -> Option<ActionTemplate> {
1260    let trimmed = action.trim();
1261    if trimmed.is_empty() {
1262        return None;
1263    }
1264    recommended_action_templates()
1265        .iter()
1266        .find(|template| template.action == trimmed)
1267        .cloned()
1268        .or_else(|| concrete_action_template(trimmed))
1269}
1270
1271pub fn action_templates(commands: &[String]) -> Vec<ActionTemplate> {
1272    commands
1273        .iter()
1274        .filter_map(|command| action_template(command))
1275        .collect()
1276}
1277
1278fn concrete_action_template(action: &str) -> Option<ActionTemplate> {
1279    if action.contains("...") || (action.contains('<') && action.contains('>')) {
1280        return None;
1281    }
1282    let argv = split_action(action).ok()?;
1283    matches!(argv.first().map(String::as_str), Some("heddle" | "git")).then(|| ActionTemplate {
1284        action: action.to_string(),
1285        argv_template: normalize_heddle_argv(argv),
1286        required_inputs: Vec::new(),
1287        agent_may_fill: false,
1288    })
1289}
1290
1291fn recommended_action_templates() -> Vec<ActionTemplate> {
1292    [
1293        (
1294            "heddle capture -m \"...\"",
1295            &["heddle", "capture", "-m", "<message>"][..],
1296            &["message"][..],
1297            true,
1298        ),
1299        (
1300            "heddle commit -m \"...\"",
1301            &["heddle", "commit", "-m", "<message>"][..],
1302            &["message"][..],
1303            true,
1304        ),
1305        ("heddle init", &["heddle", "init"][..], &[][..], false),
1306        (
1307            "heddle init --principal-name <name> --principal-email <email>",
1308            &[
1309                "heddle",
1310                "init",
1311                "--principal-name",
1312                "<name>",
1313                "--principal-email",
1314                "<email>",
1315            ][..],
1316            &["name", "email"][..],
1317            true,
1318        ),
1319        (
1320            "heddle ready -m \"...\"",
1321            &["heddle", "ready", "-m", "<message>"][..],
1322            &["message"][..],
1323            true,
1324        ),
1325        ("heddle status", &["heddle", "status"][..], &[][..], false),
1326        (
1327            "heddle thread switch <branch>",
1328            &["heddle", "switch", "<branch>"][..],
1329            &["branch"][..],
1330            false,
1331        ),
1332        ("heddle verify", &["heddle", "verify"][..], &[][..], false),
1333        ("heddle doctor", &["heddle", "doctor"][..], &[][..], false),
1334        (
1335            "heddle doctor schemas --output json",
1336            &["heddle", "doctor", "schemas", "--output", "json"][..],
1337            &[][..],
1338            false,
1339        ),
1340    ]
1341    .into_iter()
1342    .map(
1343        |(action, argv_template, required_inputs, agent_may_fill)| ActionTemplate {
1344            action: action.to_string(),
1345            argv_template: normalize_heddle_argv(
1346                argv_template.iter().map(|arg| (*arg).to_string()).collect(),
1347            ),
1348            required_inputs: required_inputs
1349                .iter()
1350                .map(|input| (*input).to_string())
1351                .collect(),
1352            agent_may_fill,
1353        },
1354    )
1355    .collect()
1356}
1357
1358fn normalize_heddle_argv(mut argv: Vec<String>) -> Vec<String> {
1359    if argv.first().is_some_and(|first| first == "heddle") {
1360        argv[0] = heddle_argv0();
1361    }
1362    argv
1363}
1364
1365fn heddle_argv0() -> String {
1366    match std::env::current_exe() {
1367        Ok(path) => {
1368            let file_name = path.file_name().and_then(|name| name.to_str());
1369            if matches!(file_name, Some("heddle") | Some("heddle.exe")) {
1370                path.display().to_string()
1371            } else {
1372                "heddle".to_string()
1373            }
1374        }
1375        Err(_) => "heddle".to_string(),
1376    }
1377}
1378
1379fn split_action(action: &str) -> std::result::Result<Vec<String>, String> {
1380    let mut args = Vec::new();
1381    let mut current = String::new();
1382    let mut chars = action.chars().peekable();
1383    let mut in_single_quote = false;
1384    let mut in_double_quote = false;
1385    while let Some(ch) = chars.next() {
1386        match (ch, in_single_quote, in_double_quote) {
1387            ('\'', false, false) => in_single_quote = true,
1388            ('\'', true, false) => in_single_quote = false,
1389            ('"', false, false) => in_double_quote = true,
1390            ('"', false, true) => in_double_quote = false,
1391            ('\\', false, _) => match chars.next() {
1392                Some(next) => current.push(next),
1393                None => current.push('\\'),
1394            },
1395            (ch, false, false) if ch.is_whitespace() => {
1396                if !current.is_empty() {
1397                    args.push(std::mem::take(&mut current));
1398                }
1399            }
1400            (ch, _, _) => current.push(ch),
1401        }
1402    }
1403    if in_single_quote || in_double_quote {
1404        return Err("unterminated quote".to_string());
1405    }
1406    if !current.is_empty() {
1407        args.push(current);
1408    }
1409    Ok(args)
1410}
1411
1412fn machine_contract_is_clean(coverage: &MachineContractCoverage) -> bool {
1413    if matches!(coverage.status.as_str(), "not_checked" | "not_applicable") {
1414        return true;
1415    }
1416    coverage.verified_scope_json_commands_without_schema == 0
1417        && coverage.verified_scope_mutating_commands_without_schema == 0
1418        && coverage.undocumented_schema_verbs_total == 0
1419        && coverage.unaccepted_opaque_schema_verbs_total == 0
1420}
1421
1422pub fn machine_contract_status(coverage: &MachineContractCoverage) -> &'static str {
1423    match coverage.status.as_str() {
1424        "not_checked" => "not_checked",
1425        "not_applicable" => "not_applicable",
1426        _ if machine_contract_is_clean(coverage) => "available",
1427        _ => "available_with_schema_gaps",
1428    }
1429}
1430
1431pub fn verify(ctx: &ExecutionContext, opts: VerifyOptions) -> Result<VerifyReport> {
1432    let fallback;
1433    let start = if let Some(start) = opts.start_path.as_deref() {
1434        start
1435    } else if let Some(start) = ctx.start_path() {
1436        start
1437    } else {
1438        fallback = std::env::current_dir().map_err(HeddleError::Io)?;
1439        fallback.as_path()
1440    };
1441
1442    // An injected Heddle repository already proves this is not the plain-Git
1443    // observe path — skip the Sley discover + `.heddle` probe that would only
1444    // return `None` after redundant work.
1445    let mut profile = VerifyProfile::default();
1446    let opened;
1447    let repo = if let Some(repo) = ctx.repo() {
1448        repo
1449    } else {
1450        let probe_start = Instant::now();
1451        let plain_git_probe = build_plain_git_verification_probe_with_machine_contract(
1452            start,
1453            &opts.machine_contract_input,
1454        )?;
1455        profile.plain_git_probe_ms = probe_start.elapsed().as_millis();
1456
1457        if let Some(probe) = plain_git_probe {
1458            return Ok(VerifyReport {
1459                output_kind: "verify",
1460                clean: probe.trust.verified,
1461                repository_label: repository_mode_label("plain-git", "git-only"),
1462                repository_context: None,
1463                trust: probe.trust,
1464                profile,
1465            });
1466        }
1467
1468        let repo_open_start = Instant::now();
1469        opened = Repository::open(start)?;
1470        profile.repo_open_ms = repo_open_start.elapsed().as_millis();
1471        &opened
1472    };
1473    let verification_start = Instant::now();
1474    let trust = build_repository_verification_state_with_machine_contract(
1475        repo,
1476        &opts.machine_contract_input,
1477    )?;
1478    profile.verification_ms = verification_start.elapsed().as_millis();
1479    let presentation = repository_presentation(repo, None, None);
1480    Ok(VerifyReport {
1481        output_kind: "verify",
1482        clean: trust.verified,
1483        repository_label: presentation.label,
1484        repository_context: presentation.context,
1485        trust,
1486        profile,
1487    })
1488}
1489
1490/// Human-facing repository mode label. JSON keeps the exact repository mode
1491/// values; text output uses product language instead of storage implementation
1492/// names.
1493pub fn repository_mode_label(capability: &str, storage_model: &str) -> String {
1494    if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
1495        "Git + Heddle".to_string()
1496    } else if capability == "plain-git" || storage_model == "git-only" {
1497        "Git repo (setup needed)".to_string()
1498    } else if capability == "native"
1499        || capability == "native-heddle"
1500        || storage_model == "heddle-native"
1501    {
1502        "Heddle native".to_string()
1503    } else {
1504        capability.to_string()
1505    }
1506}
1507
1508/// Presentation-only repository identity. This deliberately leaves
1509/// `Repository::capability_label()` untouched: an isolated checkout that shares
1510/// a Git-overlay object store is still technically opened through the native
1511/// Heddle storage path, but user-facing status should say what manages it.
1512pub fn repository_presentation(
1513    repo: &Repository,
1514    target_thread: Option<&str>,
1515    parent_thread: Option<&str>,
1516) -> RepositoryPresentation {
1517    if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
1518        let thread = current_child_thread(repo);
1519        let target_thread = target_thread.map(ToString::to_string).or_else(|| {
1520            thread
1521                .as_ref()
1522                .and_then(|thread| thread.target_thread.clone())
1523        });
1524        let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
1525            thread
1526                .as_ref()
1527                .and_then(|thread| thread.parent_thread.clone())
1528        });
1529        return RepositoryPresentation {
1530            label: "Git + Heddle isolated checkout".to_string(),
1531            context: Some(RepositoryContextInfo {
1532                kind: "git-overlay-isolated-checkout".to_string(),
1533                parent_repository: Some(parent_root.display().to_string()),
1534                target_thread,
1535                parent_thread,
1536            }),
1537        };
1538    }
1539
1540    RepositoryPresentation {
1541        label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
1542        context: None,
1543    }
1544}
1545
1546fn managed_git_overlay_parent_root(repo: &Repository) -> Option<PathBuf> {
1547    let parent_root = repo.heddle_dir().parent()?;
1548    if paths_equal(parent_root, repo.root()) {
1549        return None;
1550    }
1551    parent_root
1552        .join(".git")
1553        .exists()
1554        .then(|| parent_root.to_path_buf())
1555}
1556
1557fn current_child_thread(repo: &Repository) -> Option<Thread> {
1558    let manager = ThreadManager::new(repo.heddle_dir());
1559    if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
1560        return Some(thread);
1561    }
1562    let lane = repo.current_lane().ok().flatten()?;
1563    manager.find_by_thread(&lane).ok().flatten()
1564}
1565
1566fn paths_equal(left: &Path, right: &Path) -> bool {
1567    let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
1568    let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
1569    left == right
1570}
1571
1572pub fn dirty_path_count(status: &WorktreeStatus) -> usize {
1573    status.modified.len() + status.added.len() + status.deleted.len()
1574}
1575
1576/// Classifies the primary recommended setup action for plain-Git / import
1577/// onboarding guidance.
1578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1579pub enum RepositorySetupActionKind {
1580    Init,
1581    Adopt,
1582    GitImport,
1583    Other,
1584}
1585
1586/// Human-facing setup guidance derived from Repository Verification State.
1587///
1588/// Owned by core so status, verify, doctor, and mutation refusal text cannot
1589/// drift on setup-line / effect wording.
1590#[derive(Debug, Clone, PartialEq, Eq)]
1591pub struct RepositorySetupGuidance {
1592    pub setup_line: String,
1593    pub effect: String,
1594}
1595
1596/// Classify a recommended action string into a setup-action kind.
1597pub fn repository_setup_action_kind(action: &str) -> RepositorySetupActionKind {
1598    if action == "heddle init" {
1599        RepositorySetupActionKind::Init
1600    } else if action.starts_with("heddle adopt") {
1601        RepositorySetupActionKind::Adopt
1602    } else if action.starts_with("heddle import git") {
1603        RepositorySetupActionKind::GitImport
1604    } else {
1605        RepositorySetupActionKind::Other
1606    }
1607}
1608
1609/// Build setup guidance when verification reports `needs_init` / `needs_import`.
1610pub fn repository_setup_guidance(
1611    trust: &RepositoryVerificationState,
1612) -> Option<RepositorySetupGuidance> {
1613    if !matches!(trust.status.as_str(), "needs_init" | "needs_import") {
1614        return None;
1615    }
1616    let action = trust.recommended_action.trim();
1617    if action.is_empty() {
1618        return None;
1619    }
1620    let kind = repository_setup_action_kind(action);
1621    let setup_line = match kind {
1622        RepositorySetupActionKind::Init => {
1623            format!("Git repo detected; initialize Heddle with {action}")
1624        }
1625        RepositorySetupActionKind::Adopt => {
1626            format!("Git repo detected; connect this branch with {action}")
1627        }
1628        RepositorySetupActionKind::GitImport => {
1629            format!("Git history not imported; import it with {action}")
1630        }
1631        RepositorySetupActionKind::Other => {
1632            format!("Run {action} to clear the primary setup blocker")
1633        }
1634    };
1635    let worktree_tail = if trust.worktree_state == "clean" {
1636        "and the Git worktree stays clean"
1637    } else {
1638        "and existing Git worktree changes stay untouched"
1639    };
1640    let effect = match kind {
1641        RepositorySetupActionKind::Init => format!(
1642            ".heddle metadata will be created; Git commits stay in Git storage, {worktree_tail}."
1643        ),
1644        RepositorySetupActionKind::Adopt
1645            if trust.repository_mode == "plain-git" && !trust.heddle_initialized =>
1646        {
1647            format!(".heddle metadata will be created, Git history imported, {worktree_tail}.")
1648        }
1649        RepositorySetupActionKind::Adopt => {
1650            format!(".heddle metadata is present; adoption imports Git history {worktree_tail}.")
1651        }
1652        RepositorySetupActionKind::GitImport => {
1653            format!(".heddle metadata is present; Git history import runs {worktree_tail}.")
1654        }
1655        RepositorySetupActionKind::Other => {
1656            format!("The recommended setup command runs {worktree_tail}.")
1657        }
1658    };
1659    Some(RepositorySetupGuidance { setup_line, effect })
1660}
1661
1662#[cfg(test)]
1663mod open_amortization_tests {
1664    use super::*;
1665    use crate::ExecutionContext;
1666
1667    #[test]
1668    fn verify_uses_injected_repo_without_reopening_start_path() {
1669        let temp = tempfile::tempdir().expect("temp repo");
1670        Repository::init_default(temp.path()).expect("init repo");
1671        let repo = Repository::open(temp.path()).expect("open repo");
1672        // If verify re-opened from start_path it would fail — prove injection.
1673        let bogus = temp.path().join("not-a-repo-start");
1674        let ctx = ExecutionContext::builder()
1675            .start_path(&bogus)
1676            .repo(repo)
1677            .build();
1678
1679        let report = verify(&ctx, VerifyOptions::new().with_start_path(&bogus))
1680            .expect("verify with injected repo must not re-open start_path");
1681
1682        assert_eq!(report.output_kind, "verify");
1683        assert_eq!(
1684            report.profile.repo_open_ms, 0,
1685            "injected repo must report zero facade open cost"
1686        );
1687        assert_eq!(
1688            report.profile.plain_git_probe_ms, 0,
1689            "injected heddle repo must skip plain-git probe"
1690        );
1691        assert!(report.trust.heddle_initialized);
1692    }
1693}
1694
1695#[cfg(test)]
1696mod setup_guidance_tests {
1697    use super::*;
1698
1699    fn bare_verification_state(
1700        status: &str,
1701        recommended_action: &str,
1702    ) -> RepositoryVerificationState {
1703        RepositoryVerificationState {
1704            verified: false,
1705            status: status.to_string(),
1706            repository_mode: "plain-git".to_string(),
1707            heddle_initialized: false,
1708            git_branch: Some("main".to_string()),
1709            heddle_thread: None,
1710            worktree_dirty: false,
1711            worktree_state: "clean".to_string(),
1712            import_state: "needs_import".to_string(),
1713            mapping_state: "needs_import".to_string(),
1714            remote_drift: "not_checked".to_string(),
1715            active_operation: None,
1716            default_remote: None,
1717            clone_verification: "not_applicable".to_string(),
1718            machine_contract: "not_checked".to_string(),
1719            machine_contract_coverage: MachineContractCoverage::not_checked(),
1720            workflow_status: "not_checked".to_string(),
1721            workflow_summary: String::new(),
1722            summary: status.to_string(),
1723            recommended_action: recommended_action.to_string(),
1724            recommended_action_template: None,
1725            recovery_commands: vec![recommended_action.to_string()],
1726            recovery_action_templates: Vec::new(),
1727            checks: Vec::new(),
1728        }
1729    }
1730
1731    #[test]
1732    fn repository_setup_guidance_distinguishes_init_from_adopt() {
1733        let mut init = bare_verification_state("needs_init", "heddle init");
1734        init.import_state = "git_backed".to_string();
1735        init.mapping_state = "git_backed".to_string();
1736
1737        let guidance = repository_setup_guidance(&init).expect("init guidance");
1738        assert!(guidance.setup_line.contains("initialize Heddle"));
1739        assert!(guidance.setup_line.contains("heddle init"));
1740        assert!(guidance.effect.contains("Git commits stay in Git storage"));
1741
1742        let mut convert = bare_verification_state("needs_import", "heddle adopt --ref main");
1743        convert.repository_mode = "git-overlay".to_string();
1744        convert.heddle_initialized = true;
1745
1746        let guidance = repository_setup_guidance(&convert).expect("conversion guidance");
1747        assert!(
1748            guidance
1749                .setup_line
1750                .contains("connect this branch with heddle adopt --ref main")
1751        );
1752        assert!(guidance.effect.contains("adoption imports Git history"));
1753    }
1754
1755    #[test]
1756    fn repository_setup_guidance_skips_non_setup_statuses() {
1757        let state = bare_verification_state("dirty_worktree", "heddle capture -m \"...\"");
1758        assert!(repository_setup_guidance(&state).is_none());
1759    }
1760}