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};
14
15use crate::{
16    ExecutionContext, HeddleReport, MachineOutputKind, OutputDiscriminator, ReportContract,
17    schema_for_report,
18};
19
20use crate::status::{
21    GitOverlayHealth, build_git_overlay_health_with_worktree_status, default_remote_name,
22    git_default_remote_name_from_repo,
23};
24use crate::status::next_action::remote_tracking_status;
25use sley::{Repository as SleyRepository, ShortStatusOptions, StatusUntrackedMode, StreamControl};
26
27#[derive(Clone)]
28pub struct VerifyOptions {
29    pub start_path: Option<PathBuf>,
30}
31
32impl VerifyOptions {
33    pub fn new() -> Self {
34        Self { start_path: None }
35    }
36
37    pub fn with_start_path(mut self, start_path: impl Into<PathBuf>) -> Self {
38        self.start_path = Some(start_path.into());
39        self
40    }
41}
42
43impl Default for VerifyOptions {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
50pub struct VerifyReport {
51    pub output_kind: &'static str,
52    pub clean: bool,
53    pub repository_label: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub repository_context: Option<RepositoryContextInfo>,
56    #[serde(flatten)]
57    pub trust: RepositoryVerificationState,
58    #[serde(skip)]
59    #[schemars(skip)]
60    pub profile: VerifyProfile,
61}
62
63impl VerifyReport {
64    pub const CONTRACT: ReportContract = ReportContract {
65        schema_name: "verify",
66        machine_output_kind: MachineOutputKind::Json,
67        output_discriminator: Some(OutputDiscriminator {
68            field: "output_kind",
69            value: "verify",
70        }),
71        schema: schema_for_report::<VerifyReport>,
72    };
73}
74
75impl HeddleReport for VerifyReport {
76    const CONTRACT: ReportContract = VerifyReport::CONTRACT;
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
80pub struct VerifyProfile {
81    pub plain_git_probe_ms: u128,
82    pub repo_open_ms: u128,
83    pub verification_ms: u128,
84}
85
86#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
87pub struct RepositoryContextInfo {
88    pub kind: String,
89    pub parent_repository: Option<String>,
90    pub target_thread: Option<String>,
91    pub parent_thread: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
95pub struct RepositoryPresentation {
96    pub label: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub context: Option<RepositoryContextInfo>,
99}
100
101#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
102pub struct PlainGitVerifyProbe {
103    pub trust: RepositoryVerificationState,
104}
105
106#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
107pub struct ActionTemplate {
108    pub action: String,
109    pub argv_template: Vec<String>,
110    pub required_inputs: Vec<String>,
111    /// Whether an agent may replace placeholders in `argv_template`.
112    ///
113    /// When `agent_may_fill` is false, treat `action` and `argv_template` as
114    /// display-only: do not substitute `<name>`/`<url>` placeholders. Surface
115    /// the template to a human or discard it. Substituting and running it will
116    /// pass literal `<name>` to Heddle and fail.
117    pub agent_may_fill: bool,
118}
119
120#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
121pub struct RepositoryVerificationState {
122    #[serde(rename = "verified")]
123    pub verified: bool,
124    pub status: String,
125    pub repository_mode: String,
126    pub heddle_initialized: bool,
127    pub git_branch: Option<String>,
128    pub heddle_thread: Option<String>,
129    pub worktree_dirty: bool,
130    pub worktree_state: String,
131    pub import_state: String,
132    pub mapping_state: String,
133    pub remote_drift: String,
134    pub active_operation: Option<String>,
135    pub default_remote: Option<String>,
136    pub clone_verification: String,
137    pub machine_contract: String,
138    pub machine_contract_coverage: MachineContractCoverage,
139    pub workflow_status: String,
140    pub workflow_summary: String,
141    pub summary: String,
142    #[serde(serialize_with = "serialize_empty_action_as_null")]
143    #[schemars(with = "Option<String>")]
144    pub recommended_action: String,
145    pub recommended_action_template: Option<ActionTemplate>,
146    pub recovery_commands: Vec<String>,
147    pub recovery_action_templates: Vec<ActionTemplate>,
148    pub checks: Vec<VerificationCheck>,
149}
150
151pub fn serialize_empty_action_as_null<S>(
152    action: &String,
153    serializer: S,
154) -> std::result::Result<S::Ok, S::Error>
155where
156    S: Serializer,
157{
158    if action.is_empty() {
159        serializer.serialize_none()
160    } else {
161        serializer.serialize_some(action)
162    }
163}
164
165#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
166pub struct MachineContractCoverage {
167    pub status: String,
168    #[serde(rename = "verified_scope")]
169    pub verified_scope: String,
170    pub advanced_scope: String,
171    pub summary: String,
172    pub catalog_commands_total: usize,
173    pub catalog_mutating_commands_total: usize,
174    pub json_commands_total: usize,
175    pub json_mutating_commands_total: usize,
176    pub json_commands_with_schema: usize,
177    pub json_commands_with_accepted_opaque_schema: usize,
178    pub json_commands_without_schema: usize,
179    #[serde(rename = "verified_scope_json_commands_total")]
180    pub verified_scope_json_commands_total: usize,
181    #[serde(rename = "verified_scope_json_commands_with_schema")]
182    pub verified_scope_json_commands_with_schema: usize,
183    #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
184    pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
185    #[serde(rename = "verified_scope_json_commands_without_schema")]
186    pub verified_scope_json_commands_without_schema: usize,
187    pub advanced_scope_json_commands_total: usize,
188    pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
189    pub mutating_commands_total: usize,
190    pub mutating_commands_with_schema: usize,
191    pub mutating_commands_with_accepted_opaque_schema: usize,
192    pub mutating_commands_without_schema: usize,
193    #[serde(rename = "verified_scope_mutating_commands_total")]
194    pub verified_scope_mutating_commands_total: usize,
195    #[serde(rename = "verified_scope_mutating_commands_with_schema")]
196    pub verified_scope_mutating_commands_with_schema: usize,
197    #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
198    pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
199    #[serde(rename = "verified_scope_mutating_commands_without_schema")]
200    pub verified_scope_mutating_commands_without_schema: usize,
201    pub advanced_scope_mutating_commands_total: usize,
202    pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
203    pub schema_verbs_total: usize,
204    pub documented_schema_verbs_total: usize,
205    pub undocumented_schema_verbs_total: usize,
206    pub opaque_schema_verbs_total: usize,
207    pub accepted_opaque_schema_verbs_total: usize,
208    pub unaccepted_opaque_schema_verbs_total: usize,
209    pub supports_op_id_total: usize,
210    pub jsonl_commands_total: usize,
211    pub missing_schema_examples: Vec<String>,
212    pub missing_mutating_schema_examples: Vec<String>,
213    pub verified_scope_missing_schema_examples: Vec<String>,
214    pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
215    pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
216    pub accepted_opaque_schema_examples: Vec<String>,
217    pub unaccepted_opaque_schema_examples: Vec<String>,
218    pub undocumented_schema_examples: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
222pub struct VerificationCheck {
223    pub name: String,
224    pub status: String,
225    pub clean: bool,
226    pub summary: String,
227    pub recommended_action: Option<String>,
228    pub recommended_action_template: Option<ActionTemplate>,
229    pub recovery_commands: Vec<String>,
230    pub recovery_action_templates: Vec<ActionTemplate>,
231    #[serde(default)]
232    pub details: BTreeMap<String, String>,
233}
234
235pub fn build_plain_git_verification_probe(start: &Path) -> Result<Option<PlainGitVerifyProbe>> {
236    let git_repo = match SleyRepository::discover(start) {
237        Ok(repo) => repo,
238        Err(_) => return Ok(None),
239    };
240    let Some(workdir) = git_repo.workdir() else {
241        return Ok(None);
242    };
243    let root = workdir
244        .canonicalize()
245        .unwrap_or_else(|_| workdir.to_path_buf());
246    if root.join(".heddle").exists() {
247        return Ok(None);
248    }
249    let git_branch = git_repo
250        .head()
251        .ok()
252        .and_then(|head| head.branch_name().map(ToString::to_string));
253    let default_remote = git_default_remote_name_from_repo(&git_repo);
254    let changes_dirty = plain_git_has_changes(&git_repo)?;
255    let machine_contract_coverage = machine_contract_coverage();
256    let setup_action = "heddle init".to_string();
257    let recovery_commands = vec![setup_action.clone()];
258    let trust = RepositoryVerificationState {
259        verified: false,
260        status: "needs_init".to_string(),
261        repository_mode: "plain-git".to_string(),
262        heddle_initialized: false,
263        git_branch,
264        heddle_thread: None,
265        worktree_dirty: changes_dirty,
266        worktree_state: if changes_dirty { "dirty" } else { "clean" }.to_string(),
267        import_state: "git_backed".to_string(),
268        mapping_state: "git_backed".to_string(),
269        remote_drift: "unknown".to_string(),
270        active_operation: None,
271        default_remote,
272        clone_verification: "not_applicable".to_string(),
273        machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
274        machine_contract_coverage,
275        workflow_status: "not_checked".to_string(),
276        workflow_summary: "workflow readiness is checked after Heddle initialization".to_string(),
277        summary: "Git repository has not been initialized for Heddle".to_string(),
278        recommended_action: setup_action.clone(),
279        recommended_action_template: action_template(&setup_action),
280        recovery_commands: recovery_commands.clone(),
281        recovery_action_templates: action_templates(&recovery_commands),
282        checks: vec![
283            verification_check("Git", true, "present", "plain Git repository found", None, Vec::new()),
284            verification_check(
285                "Heddle",
286                false,
287                "needs_init",
288                "Heddle data is not initialized",
289                Some(setup_action),
290                recovery_commands,
291            ),
292            verification_check(
293                "Mapping",
294                false,
295                "not_checked",
296                "mapping is checked after Heddle initialization",
297                None,
298                Vec::new(),
299            ),
300            verification_check(
301                "Worktree",
302                false,
303                "not_checked",
304                "worktree agreement is checked after Heddle initialization",
305                None,
306                Vec::new(),
307            ),
308            verification_check(
309                "Remote",
310                false,
311                "not_checked",
312                "remote drift is checked after Heddle initialization",
313                None,
314                Vec::new(),
315            ),
316            verification_check(
317                "Operation",
318                false,
319                "not_checked",
320                "operation state is checked after Heddle initialization",
321                None,
322                Vec::new(),
323            ),
324            verification_check(
325                "Workflow",
326                false,
327                "not_checked",
328                "workflow readiness is checked after Heddle initialization",
329                None,
330                Vec::new(),
331            ),
332            verification_check(
333                "Machine contract",
334                false,
335                "not_checked",
336                "runtime schema coverage is checked after Heddle initialization",
337                None,
338                Vec::new(),
339            ),
340            verification_check(
341                "Clone",
342                true,
343                "not_applicable",
344                "clone verification is not applicable before Heddle initialization",
345                None,
346                Vec::new(),
347            ),
348        ],
349    };
350    Ok(Some(PlainGitVerifyProbe { trust }))
351}
352
353fn plain_git_has_changes(git_repo: &SleyRepository) -> Result<bool> {
354    let mut dirty = false;
355    git_repo
356        .stream_short_status_with_options(
357            ShortStatusOptions {
358                untracked_mode: StatusUntrackedMode::All,
359                ..ShortStatusOptions::default()
360            },
361            |entry| {
362                if entry.index != b' ' || entry.worktree != b' ' {
363                    dirty = true;
364                    return Ok(StreamControl::Stop);
365                }
366                Ok(StreamControl::Continue)
367            },
368        )
369        .map_err(|err| HeddleError::Config(format!("failed to inspect Git status: {err}")))?;
370    Ok(dirty)
371}
372
373pub fn build_repository_verification_state(repo: &Repository) -> Result<RepositoryVerificationState> {
374    let worktree_status = if repo.capability() == repo::RepositoryCapability::GitOverlay {
375        repo.git_overlay_worktree_status()
376    } else {
377        native_worktree_status(repo)
378    };
379    let health = build_git_overlay_health_with_worktree_status(repo, &worktree_status);
380    Ok(build_repository_verification_state_with_worktree_status(
381        repo,
382        health,
383        &worktree_status,
384    ))
385}
386
387fn native_worktree_status(repo: &Repository) -> Result<Option<WorktreeStatus>> {
388    let Some(state) = repo.current_state()? else {
389        return Ok(Some(WorktreeStatus::default()));
390    };
391    let tree = repo.require_tree(&state.tree)?;
392    repo.compare_worktree_cached(&tree).map(Some)
393}
394
395pub fn build_repository_verification_state_with_worktree_status(
396    repo: &Repository,
397    health: GitOverlayHealth,
398    worktree_status: &Result<Option<WorktreeStatus>>,
399) -> RepositoryVerificationState {
400    let git_branch = repo.git_overlay_current_branch().ok().flatten();
401    let heddle_thread = repo.current_lane().ok().flatten();
402    let active_operation = repo.operation_status().ok().flatten().map(|operation| {
403        format!("{} {} ({})", operation.scope, operation.kind, operation.state)
404    });
405    let remote_drift = repo
406        .git_remote_tracking_status()
407        .ok()
408        .flatten()
409        .map(|remote| remote_tracking_status(&remote).to_string())
410        .unwrap_or_else(|| "clean".to_string());
411    let is_git_overlay = repo.capability() == repo::RepositoryCapability::GitOverlay;
412    let import_state = health
413        .checks
414        .iter()
415        .find(|check| check.name == "import" && check.status != "clean")
416        .or_else(|| health.checks.iter().find(|check| check.name == "import"))
417        .map(|check| check.status.clone())
418        .unwrap_or_else(|| {
419            if is_git_overlay {
420                "git_backed".to_string()
421            } else {
422                "clean".to_string()
423            }
424        });
425    let mapping_state = health
426        .checks
427        .iter()
428        .find(|check| {
429            matches!(check.name.as_str(), "head_mapping" | "tag_mapping")
430                && !verification_status_is_clean(&check.status)
431        })
432        .or_else(|| {
433            health
434                .checks
435                .iter()
436                .find(|check| check.name == "head_mapping")
437        })
438        .map(|check| check.status.clone())
439        .unwrap_or_else(|| {
440            if is_git_overlay {
441                "git_backed".to_string()
442            } else {
443                "clean".to_string()
444            }
445        });
446    let git_worktree_dirty = matches!(
447        worktree_status,
448        Ok(Some(status)) if !status.is_clean()
449    );
450    let worktree_dirty = git_worktree_dirty
451        || health
452            .checks
453            .iter()
454            .any(|check| {
455                matches!(check.name.as_str(), "worktree" | "heddle_worktree")
456                    && check.status != "clean"
457            });
458    let machine_contract_coverage = machine_contract_coverage();
459    let machine_contract_clean = machine_contract_is_clean(&machine_contract_coverage);
460    let mut recovery_commands = health.recovery_commands.clone();
461    let remote_action = remote_sync_action(&health);
462    let (workflow_status, workflow_summary) = workflow_status(repo, heddle_thread.as_deref());
463    let workflow_action = if health.clean && workflow_status == "ready" {
464        workflow_primary_action(repo)
465    } else {
466        None
467    };
468    if health.clean && !machine_contract_clean {
469        recovery_commands.push("heddle doctor schemas --output json".to_string());
470    }
471    let recommended_action = if health.clean {
472        if !machine_contract_clean {
473            "heddle doctor schemas --output json".to_string()
474        } else {
475            workflow_action
476                .clone()
477                .or_else(|| remote_action.clone())
478                .unwrap_or_default()
479        }
480    } else {
481        recovery_commands.first().cloned().unwrap_or_default()
482    };
483    let checks = verification_checks_from_health(
484        &health,
485        &machine_contract_coverage,
486        is_git_overlay,
487        &workflow_status,
488        &workflow_summary,
489    );
490    RepositoryVerificationState {
491        verified: health.clean && machine_contract_clean,
492        status: if health.clean && !machine_contract_clean {
493            "machine_contract_gaps".to_string()
494        } else {
495            health.status.clone()
496        },
497        repository_mode: repo.capability_label().to_string(),
498        heddle_initialized: true,
499        git_branch,
500        heddle_thread,
501        worktree_dirty,
502        worktree_state: if worktree_dirty { "dirty" } else { "clean" }.to_string(),
503        import_state,
504        mapping_state,
505        remote_drift,
506        active_operation,
507        default_remote: default_remote_name(repo),
508        clone_verification: if repo.capability() == repo::RepositoryCapability::GitOverlay {
509            if health.clean {
510                "verified"
511            } else if matches!(health.status.as_str(), "dirty_worktree" | "needs_checkpoint") {
512                "not_checked"
513            } else {
514                "blocked"
515            }
516        } else {
517            "not_applicable"
518        }
519        .to_string(),
520        machine_contract: machine_contract_status(&machine_contract_coverage).to_string(),
521        machine_contract_coverage,
522        workflow_status,
523        workflow_summary,
524        summary: health.summary,
525        recommended_action: recommended_action.clone(),
526        recommended_action_template: action_template(&recommended_action),
527        recovery_commands: recovery_commands.clone(),
528        recovery_action_templates: action_templates(&recovery_commands),
529        checks,
530    }
531}
532
533fn verification_checks_from_health(
534    health: &GitOverlayHealth,
535    coverage: &MachineContractCoverage,
536    is_git_overlay: bool,
537    workflow_status: &str,
538    workflow_summary: &str,
539) -> Vec<VerificationCheck> {
540    let mut checks = vec![
541        git_verification_check(is_git_overlay),
542        verification_check(
543            "Heddle",
544            true,
545            "clean",
546            "Heddle data is initialized",
547            None,
548            Vec::new(),
549        ),
550        mapping_verification_check(health, is_git_overlay),
551        worktree_verification_check(health),
552        remote_verification_check(health),
553        operation_verification_check(health),
554        workflow_verification_check(health, workflow_status, workflow_summary),
555    ];
556    checks.push(machine_contract_verification_check(coverage));
557    checks.push(clone_verification_check(health, is_git_overlay));
558    checks
559}
560
561fn machine_contract_verification_check(coverage: &MachineContractCoverage) -> VerificationCheck {
562    let mut details = BTreeMap::new();
563    details.insert("coverage_status".to_string(), coverage.status.clone());
564    details.insert("coverage_summary".to_string(), coverage.summary.clone());
565    details.insert("verified_scope".to_string(), coverage.verified_scope.clone());
566    details.insert("advanced_scope".to_string(), coverage.advanced_scope.clone());
567    details.insert(
568        "catalog_commands_total".to_string(),
569        coverage.catalog_commands_total.to_string(),
570    );
571    details.insert(
572        "json_commands_total".to_string(),
573        coverage.json_commands_total.to_string(),
574    );
575    details.insert(
576        "json_commands_with_schema".to_string(),
577        coverage.json_commands_with_schema.to_string(),
578    );
579    details.insert(
580        "json_commands_without_schema".to_string(),
581        coverage.json_commands_without_schema.to_string(),
582    );
583    details.insert(
584        "json_commands_with_accepted_opaque_schema".to_string(),
585        coverage
586            .json_commands_with_accepted_opaque_schema
587            .to_string(),
588    );
589    details.insert(
590        "verified_scope_json_commands_total".to_string(),
591        coverage.verified_scope_json_commands_total.to_string(),
592    );
593    let mut check = verification_check(
594        "Machine contract",
595        machine_contract_is_clean(coverage),
596        machine_contract_status(coverage),
597        &coverage.summary,
598        (!machine_contract_is_clean(coverage))
599            .then(|| "heddle doctor schemas --output json".to_string()),
600        if machine_contract_is_clean(coverage) {
601            Vec::new()
602        } else {
603            vec!["heddle doctor schemas --output json".to_string()]
604        },
605    );
606    check.details = details;
607    check
608}
609
610fn git_verification_check(is_git_overlay: bool) -> VerificationCheck {
611    if is_git_overlay {
612        verification_check(
613            "Git",
614            true,
615            "clean",
616            "Git overlay repository is present",
617            None,
618            Vec::new(),
619        )
620    } else {
621        verification_check(
622            "Git",
623            true,
624            "not_applicable",
625            "Heddle-native repository is running in non-overlay mode",
626            None,
627            Vec::new(),
628        )
629    }
630}
631
632fn mapping_verification_check(
633    health: &GitOverlayHealth,
634    is_git_overlay: bool,
635) -> VerificationCheck {
636    if !is_git_overlay {
637        return verification_check(
638            "Mapping",
639            true,
640            "not_applicable",
641            "native Heddle refs do not require Git-overlay mapping",
642            None,
643            Vec::new(),
644        );
645    }
646    if let Some(check) = health.checks.iter().find(|check| {
647        check.name == "head_mapping" && !verification_status_is_clean(&check.status)
648    }) {
649        return verification_check_from_health("Mapping", check, health);
650    }
651    if let Some(check) = find_health_check(health, "import")
652        && check.status != "clean"
653    {
654        return verification_check_from_health("Mapping", check, health);
655    }
656    if let Some(check) = find_health_check(health, "tag_mapping")
657        && check.status != "clean"
658    {
659        return verification_check_from_health("Mapping", check, health);
660    }
661    if let Some(check) = find_health_check(health, "head_mapping") {
662        if check.status == "git_backed" && health.status == "dirty_worktree" {
663            return verification_check(
664                "Mapping",
665                true,
666                "clean",
667                "Git-backed branch mapping is not blocking verification",
668                None,
669                Vec::new(),
670            );
671        }
672        return verification_check_from_health("Mapping", check, health);
673    }
674    verification_check(
675        "Mapping",
676        true,
677        "clean",
678        "Git branch tips map to imported Heddle state",
679        None,
680        Vec::new(),
681    )
682}
683
684fn worktree_verification_check(health: &GitOverlayHealth) -> VerificationCheck {
685    for name in ["worktree", "heddle_worktree"] {
686        if let Some(check) = find_health_check(health, name)
687            && check.status != "clean"
688        {
689            return verification_check_from_health("Worktree", check, health);
690        }
691    }
692    for name in ["worktree", "heddle_worktree"] {
693        if let Some(check) = find_health_check(health, name) {
694            return verification_check_from_health("Worktree", check, health);
695        }
696    }
697    if !health.clean {
698        return verification_check(
699            "Worktree",
700            false,
701            "not_checked",
702            "worktree agreement is checked after the primary verification blocker is resolved",
703            health.recovery_commands.first().cloned(),
704            health.recovery_commands.clone(),
705        );
706    }
707    verification_check(
708        "Worktree",
709        true,
710        "clean",
711        "worktree has no uncommitted Git/Heddle disagreement",
712        None,
713        Vec::new(),
714    )
715}
716
717fn remote_verification_check(health: &GitOverlayHealth) -> VerificationCheck {
718    if let Some(check) = find_health_check(health, "remote_tracking") {
719        if matches!(check.status.as_str(), "remote_ahead" | "remote_untracked") {
720            let mut remote_check = verification_check(
721                "Remote",
722                true,
723                &check.status,
724                &check.summary,
725                remote_sync_action(health),
726                Vec::new(),
727            );
728            remote_check.details = check.details.clone();
729            return remote_check;
730        }
731        return verification_check_from_health("Remote", check, health);
732    }
733    verification_check(
734        "Remote",
735        true,
736        "clean",
737        "remote tracking has no blocking drift",
738        None,
739        Vec::new(),
740    )
741}
742
743fn operation_verification_check(health: &GitOverlayHealth) -> VerificationCheck {
744    if let Some(check) = find_health_check(health, "operation") {
745        return verification_check_from_health("Operation", check, health);
746    }
747    verification_check(
748        "Operation",
749        true,
750        "clean",
751        "no Git or Heddle operation in progress",
752        None,
753        Vec::new(),
754    )
755}
756
757fn workflow_verification_check(
758    health: &GitOverlayHealth,
759    workflow_status: &str,
760    workflow_summary: &str,
761) -> VerificationCheck {
762    if let Some(check) = find_health_check(health, "thread_integration_metadata")
763        && check.status != "clean"
764    {
765        return verification_check_from_health("Workflow", check, health);
766    }
767    if !health.clean {
768        return verification_check(
769            "Workflow",
770            false,
771            "blocked",
772            "workflow readiness is checked after the primary verification blocker is resolved",
773            health.recovery_commands.first().cloned(),
774            health.recovery_commands.clone(),
775        );
776    }
777    verification_check(
778        "Workflow",
779        true,
780        workflow_status,
781        workflow_summary,
782        None,
783        Vec::new(),
784    )
785}
786
787fn clone_verification_check(
788    health: &GitOverlayHealth,
789    is_git_overlay: bool,
790) -> VerificationCheck {
791    if !is_git_overlay {
792        return verification_check(
793            "Clone",
794            true,
795            "not_applicable",
796            "native Heddle state is the checkout authority",
797            None,
798            Vec::new(),
799        );
800    }
801    if health.clean {
802        return verification_check(
803            "Clone",
804            true,
805            "verified",
806            "Git checkout and Heddle mapping agree",
807            None,
808            Vec::new(),
809        );
810    }
811    if matches!(health.status.as_str(), "dirty_worktree" | "needs_checkpoint") {
812        return verification_check(
813            "Clone",
814            true,
815            "not_checked",
816            "clone verification waits for a clean worktree",
817            None,
818            Vec::new(),
819        );
820    }
821    verification_check(
822        "Clone",
823        false,
824        "blocked",
825        "clone verification is blocked until verification checks agree",
826        health.recovery_commands.first().cloned(),
827        health.recovery_commands.clone(),
828    )
829}
830
831fn verification_check_from_health(
832    name: &str,
833    health_check: &crate::status::GitOverlayHealthCheck,
834    health: &GitOverlayHealth,
835) -> VerificationCheck {
836    let recommended_action = (!verification_status_is_clean(&health_check.status))
837        .then(|| health.recovery_commands.first().cloned())
838        .flatten();
839    let recovery_commands = if recommended_action.is_some() {
840        health.recovery_commands.clone()
841    } else {
842        Vec::new()
843    };
844    let mut check = verification_check(
845        name,
846        verification_status_is_clean(&health_check.status),
847        &health_check.status,
848        &health_check.summary,
849        recommended_action,
850        recovery_commands,
851    );
852    check.details = health_check.details.clone();
853    check
854}
855
856fn remote_sync_action(health: &GitOverlayHealth) -> Option<String> {
857    find_health_check(health, "remote_tracking").and_then(|check| {
858        matches!(check.status.as_str(), "remote_ahead" | "remote_untracked")
859            .then(|| "heddle push".to_string())
860    })
861}
862
863fn find_health_check<'a>(
864    health: &'a GitOverlayHealth,
865    name: &str,
866) -> Option<&'a crate::status::GitOverlayHealthCheck> {
867    health.checks.iter().find(|check| check.name == name)
868}
869
870fn verification_status_is_clean(status: &str) -> bool {
871    matches!(
872        status,
873        "clean"
874            | "available"
875            | "git_backed"
876            | "not_applicable"
877            | "verified"
878            | "remote_ahead"
879            | "remote_untracked"
880    )
881}
882
883fn workflow_status(repo: &Repository, current_thread: Option<&str>) -> (String, String) {
884    let ready_threads = ThreadManager::new(repo.heddle_dir())
885        .list()
886        .unwrap_or_default()
887        .into_iter()
888        .filter(|thread| thread.state == repo::ThreadState::Ready)
889        .collect::<Vec<_>>();
890    if ready_threads.is_empty() {
891        return (
892            "clean".to_string(),
893            "no ready thread actions require attention".to_string(),
894        );
895    }
896    if ready_threads.iter().all(|thread| {
897        thread
898            .target_thread
899            .as_deref()
900            .zip(current_thread)
901            .is_some_and(|(target, current)| target != current)
902    }) {
903        return (
904            "clean".to_string(),
905            "ready thread actions target another thread".to_string(),
906        );
907    }
908    (
909        "ready".to_string(),
910        "ready thread actions are waiting to land".to_string(),
911    )
912}
913
914fn workflow_primary_action(repo: &Repository) -> Option<String> {
915    let current_thread = repo.current_lane().ok().flatten();
916    let opened_from_dedicated_checkout = repo
917        .heddle_dir()
918        .parent()
919        .is_some_and(|main_root| main_root != repo.root());
920    ThreadManager::new(repo.heddle_dir())
921        .list()
922        .ok()?
923        .into_iter()
924        .filter(|thread| thread.state == repo::ThreadState::Ready)
925        .find_map(|mut thread| {
926            let _ = refresh_thread_freshness(repo, &mut thread);
927            let actionable = thread
928                .target_thread
929                .as_deref()
930                .map(|target| {
931                    current_thread.as_deref() == Some(target) || opened_from_dedicated_checkout
932                })
933                .unwrap_or(true);
934            if !actionable {
935                return None;
936            }
937            let advice = describe_thread_advice(&thread, false, 0, false);
938            (!advice.recommended_action.trim().is_empty()).then_some(advice.recommended_action)
939        })
940}
941
942fn verification_check(
943    name: &str,
944    clean: bool,
945    status: &str,
946    summary: &str,
947    recommended_action: Option<String>,
948    recovery_commands: Vec<String>,
949) -> VerificationCheck {
950    VerificationCheck {
951        name: name.to_string(),
952        status: status.to_string(),
953        clean,
954        summary: summary.to_string(),
955        recommended_action: recommended_action.clone(),
956        recommended_action_template: recommended_action
957            .as_deref()
958            .and_then(action_template),
959        recovery_action_templates: action_templates(&recovery_commands),
960        recovery_commands,
961        details: BTreeMap::new(),
962    }
963}
964
965pub fn action_template(action: &str) -> Option<ActionTemplate> {
966    let trimmed = action.trim();
967    if trimmed.is_empty() {
968        return None;
969    }
970    recommended_action_templates()
971        .iter()
972        .find(|template| template.action == trimmed)
973        .cloned()
974        .or_else(|| concrete_action_template(trimmed))
975}
976
977pub fn action_templates(commands: &[String]) -> Vec<ActionTemplate> {
978    commands
979        .iter()
980        .filter_map(|command| action_template(command))
981        .collect()
982}
983
984fn concrete_action_template(action: &str) -> Option<ActionTemplate> {
985    if action.contains("...") || (action.contains('<') && action.contains('>')) {
986        return None;
987    }
988    let argv = split_action(action).ok()?;
989    (argv.first().map(String::as_str) == Some("heddle")).then(|| ActionTemplate {
990        action: action.to_string(),
991        argv_template: normalize_heddle_argv(argv),
992        required_inputs: Vec::new(),
993        agent_may_fill: false,
994    })
995}
996
997fn recommended_action_templates() -> Vec<ActionTemplate> {
998    [
999        ("heddle capture -m \"...\"", &["heddle", "capture", "-m", "<message>"][..], &["message"][..], true),
1000        ("heddle checkpoint -m \"...\"", &["heddle", "checkpoint", "-m", "<message>"][..], &["message"][..], true),
1001        ("heddle commit -m \"...\"", &["heddle", "commit", "-m", "<message>"][..], &["message"][..], true),
1002        ("heddle commit --all -m \"...\"", &["heddle", "commit", "--all", "-m", "<message>"][..], &["message"][..], true),
1003        ("heddle init", &["heddle", "init"][..], &[][..], false),
1004        ("heddle init --principal-name <name> --principal-email <email>", &["heddle", "init", "--principal-name", "<name>", "--principal-email", "<email>"][..], &["name", "email"][..], true),
1005        ("heddle ready -m \"...\"", &["heddle", "ready", "-m", "<message>"][..], &["message"][..], true),
1006        ("heddle status", &["heddle", "status"][..], &[][..], false),
1007        ("heddle switch <branch>", &["heddle", "switch", "<branch>"][..], &["branch"][..], false),
1008        ("heddle verify", &["heddle", "verify"][..], &[][..], false),
1009        ("heddle diagnose", &["heddle", "diagnose"][..], &[][..], false),
1010        ("heddle doctor schemas --output json", &["heddle", "doctor", "schemas", "--output", "json"][..], &[][..], false),
1011    ]
1012    .into_iter()
1013    .map(|(action, argv_template, required_inputs, agent_may_fill)| ActionTemplate {
1014        action: action.to_string(),
1015        argv_template: normalize_heddle_argv(
1016            argv_template.iter().map(|arg| (*arg).to_string()).collect(),
1017        ),
1018        required_inputs: required_inputs
1019            .iter()
1020            .map(|input| (*input).to_string())
1021            .collect(),
1022        agent_may_fill,
1023    })
1024    .collect()
1025}
1026
1027fn normalize_heddle_argv(mut argv: Vec<String>) -> Vec<String> {
1028    if argv.first().is_some_and(|first| first == "heddle") {
1029        argv[0] = heddle_argv0();
1030    }
1031    argv
1032}
1033
1034fn heddle_argv0() -> String {
1035    match std::env::current_exe() {
1036        Ok(path) => {
1037            let file_name = path.file_name().and_then(|name| name.to_str());
1038            if matches!(file_name, Some("heddle") | Some("heddle.exe")) {
1039                path.display().to_string()
1040            } else {
1041                "heddle".to_string()
1042            }
1043        }
1044        Err(_) => "heddle".to_string(),
1045    }
1046}
1047
1048fn split_action(action: &str) -> std::result::Result<Vec<String>, String> {
1049    let mut args = Vec::new();
1050    let mut current = String::new();
1051    let mut chars = action.chars().peekable();
1052    let mut in_single_quote = false;
1053    let mut in_double_quote = false;
1054    while let Some(ch) = chars.next() {
1055        match (ch, in_single_quote, in_double_quote) {
1056            ('\'', false, false) => in_single_quote = true,
1057            ('\'', true, false) => in_single_quote = false,
1058            ('"', false, false) => in_double_quote = true,
1059            ('"', false, true) => in_double_quote = false,
1060            ('\\', false, _) => match chars.next() {
1061                Some(next) => current.push(next),
1062                None => current.push('\\'),
1063            },
1064            (ch, false, false) if ch.is_whitespace() => {
1065                if !current.is_empty() {
1066                    args.push(std::mem::take(&mut current));
1067                }
1068            }
1069            (ch, _, _) => current.push(ch),
1070        }
1071    }
1072    if in_single_quote || in_double_quote {
1073        return Err("unterminated quote".to_string());
1074    }
1075    if !current.is_empty() {
1076        args.push(current);
1077    }
1078    Ok(args)
1079}
1080
1081pub fn machine_contract_coverage() -> MachineContractCoverage {
1082    let schema_verbs = BTreeSet::from([
1083        "status",
1084        "verify",
1085        "diff",
1086        "fsck",
1087        "query",
1088        "error",
1089    ]);
1090    MachineContractCoverage {
1091        status: "available".to_string(),
1092        verified_scope: "everyday_and_agent".to_string(),
1093        advanced_scope: "advanced_internal_admin".to_string(),
1094        summary: "core status/verify reports have concrete schemas".to_string(),
1095        catalog_commands_total: 3,
1096        catalog_mutating_commands_total: 0,
1097        json_commands_total: 3,
1098        json_mutating_commands_total: 0,
1099        json_commands_with_schema: 2,
1100        json_commands_with_accepted_opaque_schema: 1,
1101        json_commands_without_schema: 0,
1102        verified_scope_json_commands_total: 2,
1103        verified_scope_json_commands_with_schema: 2,
1104        verified_scope_json_commands_with_accepted_opaque_schema: 0,
1105        verified_scope_json_commands_without_schema: 0,
1106        advanced_scope_json_commands_total: 1,
1107        advanced_scope_json_commands_with_accepted_opaque_schema: 1,
1108        mutating_commands_total: 0,
1109        mutating_commands_with_schema: 0,
1110        mutating_commands_with_accepted_opaque_schema: 0,
1111        mutating_commands_without_schema: 0,
1112        verified_scope_mutating_commands_total: 0,
1113        verified_scope_mutating_commands_with_schema: 0,
1114        verified_scope_mutating_commands_with_accepted_opaque_schema: 0,
1115        verified_scope_mutating_commands_without_schema: 0,
1116        advanced_scope_mutating_commands_total: 0,
1117        advanced_scope_mutating_commands_with_accepted_opaque_schema: 0,
1118        schema_verbs_total: schema_verbs.len(),
1119        documented_schema_verbs_total: schema_verbs.len(),
1120        undocumented_schema_verbs_total: 0,
1121        opaque_schema_verbs_total: 1,
1122        accepted_opaque_schema_verbs_total: 1,
1123        unaccepted_opaque_schema_verbs_total: 0,
1124        supports_op_id_total: 1,
1125        jsonl_commands_total: 0,
1126        missing_schema_examples: Vec::new(),
1127        missing_mutating_schema_examples: Vec::new(),
1128        verified_scope_missing_schema_examples: Vec::new(),
1129        verified_scope_accepted_opaque_schema_examples: Vec::new(),
1130        advanced_scope_accepted_opaque_schema_examples: vec![
1131            "advanced/internal/admin".to_string()
1132        ],
1133        accepted_opaque_schema_examples: vec!["advanced/internal/admin".to_string()],
1134        unaccepted_opaque_schema_examples: Vec::new(),
1135        undocumented_schema_examples: Vec::new(),
1136    }
1137}
1138
1139fn machine_contract_is_clean(coverage: &MachineContractCoverage) -> bool {
1140    coverage.verified_scope_json_commands_without_schema == 0
1141        && coverage.verified_scope_mutating_commands_without_schema == 0
1142        && coverage.undocumented_schema_verbs_total == 0
1143        && coverage.unaccepted_opaque_schema_verbs_total == 0
1144}
1145
1146pub fn machine_contract_status(coverage: &MachineContractCoverage) -> &'static str {
1147    if machine_contract_is_clean(coverage) {
1148        "available"
1149    } else {
1150        "available_with_schema_gaps"
1151    }
1152}
1153
1154pub fn verify(ctx: &ExecutionContext, opts: VerifyOptions) -> Result<VerifyReport> {
1155    let fallback;
1156    let start = if let Some(start) = opts.start_path.as_deref() {
1157        start
1158    } else if let Some(start) = ctx.start_path() {
1159        start
1160    } else {
1161        fallback = std::env::current_dir().map_err(HeddleError::Io)?;
1162        fallback.as_path()
1163    };
1164
1165    let probe_start = Instant::now();
1166    let plain_git_probe = build_plain_git_verification_probe(start)?;
1167    let plain_git_probe_ms = probe_start.elapsed().as_millis();
1168    let mut profile = VerifyProfile {
1169        plain_git_probe_ms,
1170        ..VerifyProfile::default()
1171    };
1172
1173    if let Some(probe) = plain_git_probe {
1174        return Ok(VerifyReport {
1175            output_kind: "verify",
1176            clean: probe.trust.verified,
1177            repository_label: repository_mode_label("plain-git", "git-only"),
1178            repository_context: None,
1179            trust: probe.trust,
1180            profile,
1181        });
1182    }
1183
1184    let opened;
1185    let repo = if let Some(repo) = ctx.repo() {
1186        repo
1187    } else {
1188        let repo_open_start = Instant::now();
1189        opened = Repository::open(start)?;
1190        profile.repo_open_ms = repo_open_start.elapsed().as_millis();
1191        &opened
1192    };
1193    let verification_start = Instant::now();
1194    let trust = build_repository_verification_state(repo)?;
1195    profile.verification_ms = verification_start.elapsed().as_millis();
1196    let presentation = repository_presentation(repo, None, None);
1197    Ok(VerifyReport {
1198        output_kind: "verify",
1199        clean: trust.verified,
1200        repository_label: presentation.label,
1201        repository_context: presentation.context,
1202        trust,
1203        profile,
1204    })
1205}
1206
1207/// Human-facing repository mode label. JSON keeps the exact repository mode
1208/// values; text output uses product language instead of storage implementation
1209/// names.
1210pub fn repository_mode_label(capability: &str, storage_model: &str) -> String {
1211    if capability == "git-overlay" || storage_model == "git+heddle-sidecar" {
1212        "Git + Heddle".to_string()
1213    } else if capability == "plain-git" || storage_model == "git-only" {
1214        "Git repo (setup needed)".to_string()
1215    } else if capability == "native"
1216        || capability == "native-heddle"
1217        || storage_model == "heddle-native"
1218    {
1219        "Heddle native".to_string()
1220    } else {
1221        capability.to_string()
1222    }
1223}
1224
1225/// Presentation-only repository identity. This deliberately leaves
1226/// `Repository::capability_label()` untouched: an isolated checkout that shares
1227/// a Git-overlay object store is still technically opened through the native
1228/// Heddle storage path, but user-facing status should say what manages it.
1229pub fn repository_presentation(
1230    repo: &Repository,
1231    target_thread: Option<&str>,
1232    parent_thread: Option<&str>,
1233) -> RepositoryPresentation {
1234    if let Some(parent_root) = managed_git_overlay_parent_root(repo) {
1235        let thread = current_child_thread(repo);
1236        let target_thread = target_thread.map(ToString::to_string).or_else(|| {
1237            thread
1238                .as_ref()
1239                .and_then(|thread| thread.target_thread.clone())
1240        });
1241        let parent_thread = parent_thread.map(ToString::to_string).or_else(|| {
1242            thread
1243                .as_ref()
1244                .and_then(|thread| thread.parent_thread.clone())
1245        });
1246        return RepositoryPresentation {
1247            label: "Git + Heddle isolated checkout".to_string(),
1248            context: Some(RepositoryContextInfo {
1249                kind: "git-overlay-isolated-checkout".to_string(),
1250                parent_repository: Some(parent_root.display().to_string()),
1251                target_thread,
1252                parent_thread,
1253            }),
1254        };
1255    }
1256
1257    RepositoryPresentation {
1258        label: repository_mode_label(repo.capability_label(), repo.storage_model_label()),
1259        context: None,
1260    }
1261}
1262
1263fn managed_git_overlay_parent_root(repo: &Repository) -> Option<PathBuf> {
1264    let parent_root = repo.heddle_dir().parent()?;
1265    if paths_equal(parent_root, repo.root()) {
1266        return None;
1267    }
1268    parent_root
1269        .join(".git")
1270        .exists()
1271        .then(|| parent_root.to_path_buf())
1272}
1273
1274fn current_child_thread(repo: &Repository) -> Option<Thread> {
1275    let manager = ThreadManager::new(repo.heddle_dir());
1276    if let Ok(Some(thread)) = manager.find_by_execution_root(repo.root()) {
1277        return Some(thread);
1278    }
1279    let lane = repo.current_lane().ok().flatten()?;
1280    manager.find_by_thread(&lane).ok().flatten()
1281}
1282
1283fn paths_equal(left: &Path, right: &Path) -> bool {
1284    let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
1285    let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
1286    left == right
1287}
1288
1289pub fn dirty_path_count(status: &WorktreeStatus) -> usize {
1290    status.modified.len() + status.added.len() + status.deleted.len()
1291}