Skip to main content

verbs/
workflow.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Ready / land workflow domain: pure preflight, policy, and step accounting.
3//!
4//! Owns decision logic shared by `heddle ready`, `heddle land`, and `heddle sync`:
5//! - verification fail-closed preflight for readiness
6//! - land push option planning
7//! - auto-land confidence / verification policy blockers
8//! - non-staleness / heavy-impact classification
9//! - land performed / skipped step accounting
10//! - integrated-land next-action selection
11//! - ready classification and report next-action filtering
12//!
13//! Network, mutation, capture, and render remain CLI-owned.
14
15use std::path::{Path, PathBuf};
16
17use objects::object::StateId;
18use oplog::OpRecord;
19use repo::{GitImportGuidance, GitRemoteTrackingStatus, RepositoryOperationStatus, shell_quote};
20
21use crate::{
22    RepositoryVerificationState, ThreadPreviewReport,
23    status::next_action::{NextActionInput, effective_next_action, non_empty_action},
24};
25
26/// Minimum agent confidence allowed for automatic land without re-capture.
27pub const AUTO_LAND_CONFIDENCE_THRESHOLD: f32 = 0.75;
28
29/// Recovery breadcrumb when auto-land policy blocks on confidence / tests.
30pub const AUTO_LAND_CONFIDENCE_RECOVERY_ACTION: &str =
31    "heddle capture -m \"...\" --confidence <confidence>";
32
33// ---------------------------------------------------------------------------
34// Ready verification preflight
35// ---------------------------------------------------------------------------
36
37/// Statuses that must fail closed before readiness / land preflight can run.
38pub fn ready_verification_preflight_blocks(trust: &RepositoryVerificationState) -> bool {
39    ready_verification_status_blocks(trust.status.as_str())
40}
41
42/// Pure status-string check used by [`ready_verification_preflight_blocks`].
43pub fn ready_verification_status_blocks(status: &str) -> bool {
44    matches!(
45        status,
46        "needs_init" | "needs_import" | "needs_reconcile" | "git_branch_advanced"
47    )
48}
49
50// ---------------------------------------------------------------------------
51// Ready classification
52// ---------------------------------------------------------------------------
53
54/// Whether a thread preview has an integration target configured.
55pub fn has_integration_target(merge_relation: &str) -> bool {
56    merge_relation != "no_target"
57}
58
59/// Conflict-free and policy-clear: safe to mark ready / land.
60pub fn is_integration_clear(conflict_count: usize, blockers: &[String]) -> bool {
61    conflict_count == 0 && blockers.is_empty()
62}
63
64/// Inputs for classifying a ready-command outcome without performing I/O.
65#[derive(Debug, Clone, Copy)]
66pub struct ReadyDecisionInput<'a> {
67    pub merge_relation: &'a str,
68    pub captured: bool,
69    /// Whether the thread is already in [`repo::ThreadState::Ready`].
70    pub thread_already_ready: bool,
71    pub conflict_count: usize,
72    pub blockers: &'a [String],
73}
74
75/// Pure classification of readiness after preview / policy blockers are known.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ReadyDecision {
78    pub has_integration_target: bool,
79    /// Thread was already ready and no capture ran this invocation.
80    pub already_ready: bool,
81    /// Clean thread with no integration target configured.
82    pub ready_without_target: bool,
83    /// Conflict-free and no blockers (would mark Ready when a target exists).
84    pub integration_clear: bool,
85    /// Operator envelope should report `completed` (ready or no-target clean).
86    pub operator_completed: bool,
87}
88
89/// Classify ready outcome from preview facts (no I/O).
90pub fn classify_ready_decision(input: ReadyDecisionInput<'_>) -> ReadyDecision {
91    let has_target = has_integration_target(input.merge_relation);
92    let clear = is_integration_clear(input.conflict_count, input.blockers);
93    let already_ready = has_target && !input.captured && input.thread_already_ready && clear;
94    let ready_without_target = !has_target && clear;
95    ReadyDecision {
96        has_integration_target: has_target,
97        already_ready,
98        ready_without_target,
99        integration_clear: clear,
100        // Completed when the thread is (or would be) Ready after this
101        // invocation, or clean without a target. A blocker without a target
102        // (concurrent source heads) still blocks.
103        operator_completed: clear,
104    }
105}
106
107/// Drop self-merge / land recommendations when the thread has no target.
108pub fn ready_report_recommended_action(
109    merge_relation: &str,
110    recommended_action: &str,
111) -> Option<String> {
112    // Resolving concurrent source heads is Thread-local work, so it stays
113    // the next action even without an integration target.
114    if merge_relation == "no_target"
115        && recommended_action != crate::source_heads::SOURCE_HEADS_ACTION
116    {
117        return None;
118    }
119    non_empty_action(Some(recommended_action)).map(str::to_string)
120}
121
122/// Ready-scoped next-action selection (operation → thread fallback → publish).
123pub fn ready_scoped_next_action(
124    operation: Option<&RepositoryOperationStatus>,
125    remote_tracking: Option<&GitRemoteTrackingStatus>,
126    import_hint: Option<&GitImportGuidance>,
127    thread_action: Option<&str>,
128) -> String {
129    effective_next_action(
130        NextActionInput::default(operation, remote_tracking, import_hint, thread_action).ready(),
131    )
132}
133
134/// Whether land should squash the thread into one Git commit on write-through.
135pub fn should_squash_land(no_squash: bool, config_squash: bool) -> bool {
136    !no_squash && config_squash
137}
138
139// ---------------------------------------------------------------------------
140// Auto-land policy
141// ---------------------------------------------------------------------------
142
143/// Facts needed for auto-land policy without opening the object store.
144#[derive(Debug, Clone, Copy)]
145pub struct AutoLandPolicyInput {
146    pub agent_authored: bool,
147    pub confidence: Option<f32>,
148    pub tests_passed: Option<bool>,
149}
150
151/// Policy blockers that prevent automatic land (confidence / verification).
152pub fn auto_land_policy_blockers(input: AutoLandPolicyInput) -> Vec<String> {
153    let mut blockers = Vec::new();
154    if input.agent_authored
155        && let Some(confidence) = input.confidence
156        && confidence < AUTO_LAND_CONFIDENCE_THRESHOLD
157    {
158        blockers.push(format!(
159            "confidence {:.2} is below the auto-land threshold of {AUTO_LAND_CONFIDENCE_THRESHOLD:.2}",
160            confidence
161        ));
162    }
163    if matches!(input.tests_passed, Some(false)) {
164        blockers.push("verification summary reports failing tests".to_string());
165    }
166    blockers
167}
168
169/// Combine preview blockers with auto-land policy, honoring manual resolution.
170pub fn integration_blockers(
171    manual_resolution_current: bool,
172    preview_blockers: &[String],
173    policy: AutoLandPolicyInput,
174) -> Vec<String> {
175    let mut blockers = if manual_resolution_current {
176        Vec::new()
177    } else {
178        non_staleness_blockers(preview_blockers)
179    };
180    blockers.extend(auto_land_policy_blockers(policy));
181    blockers
182}
183
184/// Recovery breadcrumb for confidence / verification policy blockers.
185pub fn integration_blocker_recommended_action(
186    blockers: &[String],
187    scope_to_checkout: Option<&Path>,
188) -> Option<String> {
189    blockers
190        .iter()
191        .any(|blocker| {
192            blocker.starts_with("confidence ")
193                || blocker == "verification summary reports failing tests"
194        })
195        .then(|| auto_land_confidence_recovery_action(scope_to_checkout))
196}
197
198/// Scope the confidence recovery capture to the thread's checkout when needed.
199pub fn auto_land_confidence_recovery_action(scope_to_checkout: Option<&Path>) -> String {
200    match scope_to_checkout {
201        Some(path) => format!(
202            "heddle --repo {} {}",
203            shell_quote(&path.display().to_string()),
204            AUTO_LAND_CONFIDENCE_RECOVERY_ACTION
205                .strip_prefix("heddle ")
206                .expect("recovery action is a heddle command"),
207        ),
208        None => AUTO_LAND_CONFIDENCE_RECOVERY_ACTION.to_string(),
209    }
210}
211
212/// Returns the thread checkout when it is a real, distinct path from the
213/// current checkout (so recovery breadcrumbs must re-scope via `--repo`).
214pub fn recovery_scope_checkout(execution_path: &Path, current_checkout: &Path) -> Option<PathBuf> {
215    if execution_path.as_os_str().is_empty() {
216        return None;
217    }
218    let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
219    (canonical(execution_path) != canonical(current_checkout)).then(|| execution_path.to_path_buf())
220}
221
222// ---------------------------------------------------------------------------
223// Blocker classification / land preview surface
224// ---------------------------------------------------------------------------
225
226/// Heavy-impact lines are advisories for land, not hard blockers for sync.
227pub fn is_heavy_impact_advisory(blocker: &str) -> bool {
228    blocker.to_lowercase().contains("heavy-impact change")
229}
230
231/// Drop staleness and heavy-impact advisories from a blocker list.
232pub fn non_staleness_blockers(blockers: &[String]) -> Vec<String> {
233    blockers
234        .iter()
235        .filter(|blocker| {
236            !blocker.contains(" is stale against ") && !is_heavy_impact_advisory(blocker)
237        })
238        .cloned()
239        .collect()
240}
241
242/// Expand preview conflicts into land blockers, then sort/dedup.
243pub fn land_blockers_for_preview(
244    preview: &ThreadPreviewReport,
245    blockers: &[String],
246) -> Vec<String> {
247    let mut out = blockers.to_vec();
248    if preview.conflict_count > 0 {
249        out.push(format!(
250            "{} path conflict(s) need manual resolution",
251            preview.conflict_count
252        ));
253        out.extend(
254            preview
255                .conflicts
256                .iter()
257                .map(|path| format!("conflict: {path}")),
258        );
259    }
260    out.sort();
261    out.dedup();
262    out
263}
264
265/// Heavy-impact advisories for land (warnings, not hard blockers).
266pub fn land_warnings_for_preview(preview: &ThreadPreviewReport) -> Vec<String> {
267    let mut warnings = preview
268        .blockers
269        .iter()
270        .filter(|blocker| is_heavy_impact_advisory(blocker))
271        .cloned()
272        .collect::<Vec<_>>();
273    if warnings.is_empty() && !preview.heavy_impact_paths.is_empty() {
274        warnings.push(format!(
275            "Heavy-impact change: {} — review broader impact before merging",
276            preview.heavy_impact_paths.join(", ")
277        ));
278    }
279    warnings.sort();
280    warnings.dedup();
281    warnings
282}
283
284// ---------------------------------------------------------------------------
285// Land step accounting + post-integrate next action
286// ---------------------------------------------------------------------------
287
288/// Whether a land/preview blocker is the heavy-impact manual-review advisory.
289pub fn is_manual_review_blocker(blocker: &str) -> bool {
290    blocker.starts_with("Heavy-impact change:")
291}
292
293/// Human text for a land performed/skipped step token.
294pub fn land_text_step(step: &str) -> String {
295    match step {
296        "capture" => "saved".to_string(),
297        "sync" => "refreshed".to_string(),
298        "merge" => "merged".to_string(),
299        "checkpoint" => "committed".to_string(),
300        "capture(no changes)" => "no unsaved changes".to_string(),
301        "sync(current)" => "already refreshed".to_string(),
302        "merge(blocked)" => "merge blocked".to_string(),
303        "merge(already_integrated)" => "already landed".to_string(),
304        "checkpoint(not needed)" => "no Git commit needed".to_string(),
305        "checkpoint(not reached)" => "Git commit skipped because merge did not run".to_string(),
306        other => other.to_string(),
307    }
308}
309
310/// Scope a `heddle …` recommended action to an explicit `--repo` path.
311pub fn scope_action_to_repo(action: &str, repo_path: &str) -> String {
312    let Some(rest) = action.strip_prefix("heddle ") else {
313        return action.to_string();
314    };
315    if rest.starts_with("--repo ") || rest.starts_with("-R ") {
316        return action.to_string();
317    }
318    format!(
319        "heddle --repo {} {rest}",
320        quote_recommended_action_arg(repo_path)
321    )
322}
323
324/// Quote a recommended-action path/arg when it is not shell-safe bare.
325pub fn quote_recommended_action_arg(value: &str) -> String {
326    if !value.is_empty()
327        && value
328            .bytes()
329            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'))
330    {
331        value.to_string()
332    } else {
333        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
334    }
335}
336
337/// Ready summary labels from preview merge relation strings.
338pub fn ready_merge_type_label(result: &str) -> String {
339    match result {
340        "fast_forward" => "fast-forward".to_string(),
341        "already_integrated" => "already integrated".to_string(),
342        "no_target" => "none configured".to_string(),
343        other => other.replace('_', " "),
344    }
345}
346
347/// Ready status line for operator text (`clean` vs thread health).
348pub fn ready_status_summary(
349    merge_relation: &str,
350    blockers_empty: bool,
351    thread_health: &str,
352) -> String {
353    if merge_relation == "no_target" && blockers_empty {
354        "clean".to_string()
355    } else {
356        thread_health.replace('_', " ")
357    }
358}
359
360/// Integration column for ready summary.
361pub fn ready_integration_summary(merge_relation: &str) -> String {
362    match merge_relation {
363        "no_target" => "n/a (no integration target configured)".to_string(),
364        "not_checked" => "not checked (readiness checks did not run)".to_string(),
365        "blocked" => "not checked (repository verification is blocked)".to_string(),
366        _ => "configured".to_string(),
367    }
368}
369
370/// Freshness column for ready summary.
371pub fn ready_freshness_summary(merge_relation: &str, freshness: &str) -> String {
372    match merge_relation {
373        "no_target" => "n/a (no integration target configured)".to_string(),
374        "not_checked" => "not checked (readiness checks did not run)".to_string(),
375        "blocked" => "not checked (repository verification is blocked)".to_string(),
376        _ => freshness.replace('_', " "),
377    }
378}
379
380/// Merge-type column for ready summary.
381pub fn ready_merge_type_summary(merge_relation: &str) -> String {
382    match merge_relation {
383        "no_target" => "n/a (no integration target configured)".to_string(),
384        "not_checked" => "not checked (readiness checks did not run)".to_string(),
385        "blocked" => "not checked (repository verification is blocked)".to_string(),
386        other => ready_merge_type_label(other),
387    }
388}
389
390/// Steps that actually ran during land.
391pub fn land_performed_steps(
392    captured: bool,
393    synced: bool,
394    integrated: bool,
395    checkpointed: bool,
396) -> Vec<String> {
397    [
398        (captured, "capture"),
399        (synced, "sync"),
400        (integrated, "merge"),
401        (checkpointed, "checkpoint"),
402    ]
403    .into_iter()
404    .filter(|&(done, _step)| done)
405    .map(|(_done, step)| step.to_string())
406    .collect()
407}
408
409/// Steps skipped (with reason tokens) during land.
410pub fn land_skipped_steps(
411    captured: bool,
412    synced: bool,
413    integrated: bool,
414    checkpointed: bool,
415) -> Vec<String> {
416    [
417        (!captured, "capture(no changes)"),
418        (!synced, "sync(current)"),
419        (!integrated, "merge(blocked)"),
420        (!checkpointed && integrated, "checkpoint(not needed)"),
421        (!checkpointed && !integrated, "checkpoint(not reached)"),
422    ]
423    .into_iter()
424    .filter(|&(skipped, _step)| skipped)
425    .map(|(_skipped, step)| step.to_string())
426    .collect()
427}
428
429/// Next action after a successful sync (already current or refreshed).
430///
431/// Sync is its own job — refresh this thread onto its target — not land.
432/// Operation / remote / import still win. There is no `land --thread`
433/// fallback: a completed sync does not prescribe landing.
434pub fn sync_completed_next_action(
435    operation: Option<&RepositoryOperationStatus>,
436    remote_tracking: Option<&GitRemoteTrackingStatus>,
437    import_hint: Option<&GitImportGuidance>,
438) -> Option<String> {
439    non_empty_action(Some(&effective_next_action(NextActionInput::default(
440        operation,
441        remote_tracking,
442        import_hint,
443        None,
444    ))))
445    .map(str::to_string)
446}
447
448/// Whether sync has nothing left to refresh.
449///
450/// The no-op is freshness-current **or** the synthesized default
451/// checkout (a HEAD-attached ref with no ThreadManager record, often
452/// named `main`). A persisted managed thread with `target_thread: None`
453/// (created from detached HEAD) is **not** a no-op — it must reach
454/// `refresh_thread` and surface `missing_target_thread`.
455pub fn sync_is_already_current(freshness_is_current: bool, is_synthesized_checkout: bool) -> bool {
456    freshness_is_current || is_synthesized_checkout
457}
458
459/// Next action after a successful local land (push if trust says so, else cleanup).
460pub fn integrated_land_next_action(
461    integrated: bool,
462    trust_recommended_action: &str,
463) -> Option<String> {
464    if !integrated {
465        return None;
466    }
467    if trust_recommended_action == "heddle push" {
468        Some(trust_recommended_action.to_string())
469    } else {
470        Some("heddle thread cleanup --merged --dry-run".to_string())
471    }
472}
473
474/// Checkpoint / squash message for land write-through.
475///
476/// Precedence: explicit message → task/name for a squash → capture intent →
477/// task/name. Generated fallbacks avoid opaque identities; explicit messages
478/// are kept as supplied, subject to Git subject formatting.
479pub fn land_checkpoint_message(
480    explicit: Option<&str>,
481    prefer_land_subject: bool,
482    thread_name: &str,
483    intent: Option<&str>,
484    task: Option<&str>,
485) -> String {
486    if let Some(message) = explicit {
487        return git_subject_message(message);
488    }
489    let task = task.filter(|task| !subject_has_machine_identity(task));
490    let name = (!looks_like_machine_identity(thread_name)).then_some(thread_name);
491    let intent = intent.filter(|intent| !subject_has_machine_identity(intent));
492    let chosen = if prefer_land_subject {
493        task.or(name).or(intent)
494    } else {
495        intent.or(task).or(name)
496    };
497    git_subject_message(chosen.unwrap_or("Land thread"))
498}
499
500/// Keep Git's first line brief while retaining the full intent in the body.
501pub fn git_subject_message(message: &str) -> String {
502    let chosen = message.trim();
503    let first_line = chosen.lines().next().unwrap_or(chosen);
504    let subject = first_line.trim();
505    let detail = chosen[first_line.len()..].trim();
506    if subject.chars().count() <= 72 {
507        return if detail.is_empty() {
508            subject.to_string()
509        } else {
510            format!("{subject}\n\n{detail}")
511        };
512    }
513    let prefix: String = subject.chars().take(72).collect();
514    let short = prefix
515        .rsplit_once(char::is_whitespace)
516        .map(|(head, _)| head)
517        .filter(|head| !head.is_empty())
518        .unwrap_or(&prefix);
519    format!("{}\n\n{chosen}", short.trim_end())
520}
521
522/// Match tapestry's identity-label rule for values that should not be names.
523pub fn looks_like_machine_identity(value: &str) -> bool {
524    let text = value.trim();
525    if text.is_empty() {
526        return true;
527    }
528    let uuid = text.len() == 36
529        && text.bytes().enumerate().all(|(i, byte)| {
530            if [8, 13, 18, 23].contains(&i) {
531                byte == b'-'
532            } else {
533                byte.is_ascii_hexdigit()
534            }
535        });
536    let long_hex = text.len() >= 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit());
537    let prefixed = ["principal:", "agent:", "device:", "device-key:"]
538        .iter()
539        .any(|prefix| text.to_ascii_lowercase().starts_with(prefix));
540    let keyed = text.split_once(':').is_some_and(|(key, value)| {
541        key.len() >= 8
542            && key
543                .bytes()
544                .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
545            && value
546                .split(|ch: char| !ch.is_ascii_hexdigit())
547                .any(|part| part.len() >= 16)
548    });
549    uuid || long_hex || prefixed || keyed || text.starts_with("spool:git:")
550}
551
552/// Find a long hex identity or UUID even when a label is attached to it.
553pub fn machine_identity_span(text: &str) -> Option<(usize, usize)> {
554    for (start, ch) in text.char_indices() {
555        if !ch.is_ascii_hexdigit() {
556            continue;
557        }
558        let rest = &text[start..];
559        if rest.as_bytes().get(..36).is_some_and(|candidate| {
560            candidate.iter().copied().enumerate().all(|(index, byte)| {
561                if [8, 13, 18, 23].contains(&index) {
562                    byte == b'-'
563                } else {
564                    byte.is_ascii_hexdigit()
565                }
566            })
567        }) {
568            return Some((start, start + 36));
569        }
570        let hex_len = rest.bytes().take_while(u8::is_ascii_hexdigit).count();
571        if hex_len >= 16 {
572            return Some((start, start + hex_len));
573        }
574    }
575    None
576}
577
578/// Detect an opaque identity anywhere in the first line of a Git message.
579pub fn subject_has_machine_identity(message: &str) -> bool {
580    let subject = message.lines().next().unwrap_or_default();
581    subject.trim().is_empty()
582        || machine_identity_span(subject).is_some()
583        || subject
584            .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'))
585            .filter(|token| !token.is_empty())
586            .any(looks_like_machine_identity)
587}
588
589/// Whether a change id matches a short or full display form from operator text.
590pub fn state_id_matches_display(short: &str, full: &str, display: &str) -> bool {
591    short == display || full == display
592}
593
594/// Whether an oplog record advances HEAD/thread to a land merge target.
595pub fn op_targets_merge_state(op: &OpRecord, merge_state: &str) -> bool {
596    match op {
597        OpRecord::Snapshot { new_state, .. } => {
598            state_id_matches_display(&new_state.short(), &new_state.to_string_full(), merge_state)
599        }
600        OpRecord::Checkpoint { state, .. } => {
601            state_id_matches_display(&state.short(), &state.to_string_full(), merge_state)
602        }
603        OpRecord::Goto { target, .. } => {
604            state_id_matches_display(&target.short(), &target.to_string_full(), merge_state)
605        }
606        OpRecord::FastForward { post_target_id, .. } => state_id_matches_display(
607            &post_target_id.short(),
608            &post_target_id.to_string_full(),
609            merge_state,
610        ),
611        // Enumerated explicitly (no wildcard) so a new state-advancing variant
612        // must be considered here (heddle#354 r9).
613        OpRecord::ThreadCreate { .. }
614        | OpRecord::ThreadDelete { .. }
615        | OpRecord::ThreadUpdate { .. }
616        | OpRecord::Fork { .. }
617        | OpRecord::Collapse { .. }
618        | OpRecord::MarkerCreate { .. }
619        | OpRecord::MarkerDelete { .. }
620        | OpRecord::TransactionAbort { .. }
621        | OpRecord::EphemeralThreadCollapse { .. }
622        | OpRecord::ConflictResolved { .. }
623        | OpRecord::TransactionCommit { .. }
624        | OpRecord::Redact { .. }
625        | OpRecord::Purge { .. }
626        | OpRecord::GitCheckpoint { .. }
627        | OpRecord::RemoteThreadUpdate { .. }
628        | OpRecord::RemoteThreadDelete { .. }
629        | OpRecord::UndoRecoveryUpdate { .. }
630        | OpRecord::StateVisibilitySet { .. }
631        | OpRecord::StateVisibilityPromote { .. }
632        | OpRecord::EntryVisibilitySet { .. }
633        | OpRecord::HeadUpdate { .. } => false,
634    }
635}
636
637/// Convenience: match a [`StateId`] against operator display text.
638pub fn state_id_matches_op_display(id: &StateId, display: &str) -> bool {
639    state_id_matches_display(&id.short(), &id.to_string_full(), display)
640}
641
642#[cfg(test)]
643mod tests {
644    use repo::{OperationKind, OperationScope};
645
646    use super::*;
647    use crate::status::next_action as core_next_action;
648
649    fn bare_trust(status: &str) -> RepositoryVerificationState {
650        RepositoryVerificationState {
651            verified: false,
652            status: status.to_string(),
653            repository_mode: "native".to_string(),
654            heddle_initialized: true,
655            git_branch: None,
656            heddle_thread: None,
657            worktree_dirty: false,
658            worktree_state: "clean".to_string(),
659            import_state: "ok".to_string(),
660            mapping_state: "ok".to_string(),
661            remote_drift: "none".to_string(),
662            active_operation: None,
663            default_remote: None,
664            clone_verification: "not_applicable".to_string(),
665            machine_contract: "not_checked".to_string(),
666            machine_contract_coverage: crate::MachineContractCoverage::not_checked(),
667            workflow_status: "idle".to_string(),
668            workflow_summary: String::new(),
669            summary: status.to_string(),
670            recommended_action: "heddle verify".to_string(),
671            recommended_action_template: None,
672            recovery_commands: Vec::new(),
673            recovery_action_templates: Vec::new(),
674            checks: Vec::new(),
675        }
676    }
677
678    fn preview(merge_relation: &str) -> ThreadPreviewReport {
679        ThreadPreviewReport {
680            thread: "feature".to_string(),
681            thread_mode: "solid".to_string(),
682            thread_state: "ready".to_string(),
683            freshness: "current".to_string(),
684            task: None,
685            changed_paths: Vec::new(),
686            changed_path_count: 0,
687            impact_categories: Vec::new(),
688            heavy_impact_paths: Vec::new(),
689            merge_relation: merge_relation.to_string(),
690            conflicts: Vec::new(),
691            conflict_count: 0,
692            blockers: Vec::new(),
693            recommended_action: "heddle land --thread feature".to_string(),
694            recommended_action_template: None,
695            thread_health: "ready".to_string(),
696        }
697    }
698
699    #[test]
700    fn ready_preflight_blocks_setup_and_mapping_statuses() {
701        for status in [
702            "needs_init",
703            "needs_import",
704            "needs_reconcile",
705            "git_branch_advanced",
706        ] {
707            assert!(
708                ready_verification_preflight_blocks(&bare_trust(status)),
709                "{status} should block ready preflight"
710            );
711        }
712        assert!(!ready_verification_preflight_blocks(&bare_trust("clean")));
713        assert!(!ready_verification_preflight_blocks(&bare_trust(
714            "dirty_worktree"
715        )));
716    }
717
718    #[test]
719    fn ready_decision_classifies_already_ready_and_no_target() {
720        let clear = classify_ready_decision(ReadyDecisionInput {
721            merge_relation: "fast_forward",
722            captured: false,
723            thread_already_ready: true,
724            conflict_count: 0,
725            blockers: &[],
726        });
727        assert!(clear.already_ready);
728        assert!(clear.integration_clear);
729        assert!(clear.operator_completed);
730
731        let no_target = classify_ready_decision(ReadyDecisionInput {
732            merge_relation: "no_target",
733            captured: false,
734            thread_already_ready: false,
735            conflict_count: 0,
736            blockers: &[],
737        });
738        assert!(no_target.ready_without_target);
739        assert!(!no_target.has_integration_target);
740        assert!(no_target.operator_completed);
741
742        let heads = [
743            "thread 'main' has 2 unresolved alternative source heads; pick or merge one"
744                .to_string(),
745        ];
746        let blocked_without_target = classify_ready_decision(ReadyDecisionInput {
747            merge_relation: "no_target",
748            captured: false,
749            thread_already_ready: false,
750            conflict_count: 0,
751            blockers: &heads,
752        });
753        assert!(!blocked_without_target.ready_without_target);
754        assert!(!blocked_without_target.operator_completed);
755
756        let blocked = classify_ready_decision(ReadyDecisionInput {
757            merge_relation: "conflicted",
758            captured: false,
759            thread_already_ready: false,
760            conflict_count: 1,
761            blockers: &["conflict".to_string()],
762        });
763        assert!(!blocked.integration_clear);
764        assert!(!blocked.operator_completed);
765    }
766
767    #[test]
768    fn ready_suppresses_action_without_target() {
769        assert_eq!(
770            ready_report_recommended_action("no_target", "heddle land --thread main"),
771            None
772        );
773        assert_eq!(
774            ready_report_recommended_action("no_target", "heddle resolve --heads"),
775            Some("heddle resolve --heads".to_string())
776        );
777        assert_eq!(
778            ready_report_recommended_action("fast_forward", "heddle land --thread feature"),
779            Some("heddle land --thread feature".to_string())
780        );
781    }
782
783    #[test]
784    fn ready_scoped_next_action_matches_core_matrix() {
785        let operation = RepositoryOperationStatus {
786            scope: OperationScope::Heddle,
787            kind: OperationKind::Merge,
788            in_progress: true,
789            state: "in_progress".to_string(),
790            message: "merge in progress".to_string(),
791            next_action: "heddle continue".to_string(),
792        };
793        let remote_ahead = GitRemoteTrackingStatus {
794            branch: "feature".to_string(),
795            upstream: "origin/feature".to_string(),
796            ahead: 1,
797            behind: 0,
798            local_oid: Some("local".to_string()),
799            upstream_oid: Some("upstream".to_string()),
800            upstream_is_undone_checkpoint: false,
801            message: String::new(),
802            next_action: String::new(),
803        };
804        let fallback = Some("heddle land --thread feature");
805        let scoped = ready_scoped_next_action(Some(&operation), None, None, fallback);
806        let core = core_next_action::effective_next_action(
807            core_next_action::NextActionInput::default(Some(&operation), None, None, fallback)
808                .ready(),
809        );
810        assert_eq!(scoped, core);
811        assert_eq!(scoped, "heddle continue");
812
813        let publish = ready_scoped_next_action(None, Some(&remote_ahead), None, None);
814        assert_eq!(
815            publish,
816            core_next_action::effective_next_action(
817                core_next_action::NextActionInput::default(None, Some(&remote_ahead), None, None,)
818                    .ready(),
819            )
820        );
821    }
822
823    #[test]
824    fn auto_land_policy_blocks_low_confidence_and_failing_tests() {
825        let blockers = auto_land_policy_blockers(AutoLandPolicyInput {
826            agent_authored: true,
827            confidence: Some(0.40),
828            tests_passed: Some(false),
829        });
830        assert_eq!(
831            blockers,
832            vec![
833                "confidence 0.40 is below the auto-land threshold of 0.75".to_string(),
834                "verification summary reports failing tests".to_string(),
835            ]
836        );
837        assert!(
838            auto_land_policy_blockers(AutoLandPolicyInput {
839                agent_authored: false,
840                confidence: Some(0.10),
841                tests_passed: Some(true),
842            })
843            .is_empty()
844        );
845    }
846
847    #[test]
848    fn confidence_blocker_recovery_scopes_to_thread_checkout() {
849        let blockers = vec!["confidence 0.40 is below the auto-land threshold of 0.75".to_string()];
850        let action = integration_blocker_recommended_action(
851            &blockers,
852            Some(Path::new("/work/threads/agent-thread")),
853        )
854        .expect("confidence blocker must yield recovery");
855        assert_eq!(
856            action,
857            "heddle --repo /work/threads/agent-thread capture -m \"...\" --confidence <confidence>"
858        );
859
860        let unscoped =
861            integration_blocker_recommended_action(&blockers, None).expect("unscoped recovery");
862        assert_eq!(unscoped, AUTO_LAND_CONFIDENCE_RECOVERY_ACTION);
863
864        assert!(
865            integration_blocker_recommended_action(
866                &["3 path conflict(s) need manual resolution".to_string()],
867                None
868            )
869            .is_none()
870        );
871    }
872
873    #[test]
874    fn non_staleness_drops_stale_and_heavy_impact() {
875        let blockers = vec![
876            "Thread 'agent-thread' is stale against 'main'".to_string(),
877            "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
878                .to_string(),
879            "confidence 0.40 is below the auto-land threshold of 0.75".to_string(),
880        ];
881        assert_eq!(
882            non_staleness_blockers(&blockers),
883            vec!["confidence 0.40 is below the auto-land threshold of 0.75".to_string()]
884        );
885    }
886
887    #[test]
888    fn land_warnings_surface_heavy_impact_review() {
889        let mut report = preview("would_merge");
890        report.heavy_impact_paths = vec!["crates/wire/src/lib.rs".to_string()];
891        report.blockers = vec![
892            "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
893                .to_string(),
894        ];
895        assert_eq!(
896            land_warnings_for_preview(&report),
897            vec![
898                "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
899                    .to_string()
900            ]
901        );
902    }
903
904    #[test]
905    fn land_step_accounting_and_next_action() {
906        assert_eq!(
907            land_performed_steps(true, false, true, true),
908            vec!["capture", "merge", "checkpoint"]
909        );
910        assert!(land_skipped_steps(true, true, true, true).is_empty());
911        assert_eq!(
912            integrated_land_next_action(true, "heddle push"),
913            Some("heddle push".to_string())
914        );
915        assert_eq!(
916            integrated_land_next_action(true, "heddle push"),
917            Some("heddle push".to_string())
918        );
919        assert_eq!(integrated_land_next_action(false, "heddle push"), None);
920    }
921
922    #[test]
923    fn sync_completed_next_action_is_not_land_thread() {
924        assert_eq!(sync_completed_next_action(None, None, None), None);
925
926        let operation = RepositoryOperationStatus {
927            scope: OperationScope::Heddle,
928            kind: OperationKind::Merge,
929            in_progress: true,
930            state: "in_progress".to_string(),
931            message: "merge in progress".to_string(),
932            next_action: "heddle continue".to_string(),
933        };
934        assert_eq!(
935            sync_completed_next_action(Some(&operation), None, None),
936            Some("heddle continue".to_string())
937        );
938
939        assert!(sync_is_already_current(true, false));
940        assert!(sync_is_already_current(false, true));
941        assert!(!sync_is_already_current(false, false));
942    }
943
944    #[test]
945    fn recovery_scope_checkout_distinguishes_isolated_from_in_thread() {
946        assert_eq!(
947            recovery_scope_checkout(
948                Path::new("/work/threads/agent-thread"),
949                Path::new("/work/parent"),
950            ),
951            Some(PathBuf::from("/work/threads/agent-thread")),
952        );
953        assert_eq!(
954            recovery_scope_checkout(
955                Path::new("/work/threads/agent-thread"),
956                Path::new("/work/threads/agent-thread"),
957            ),
958            None,
959        );
960        assert_eq!(
961            recovery_scope_checkout(Path::new(""), Path::new("/work/parent")),
962            None,
963        );
964    }
965
966    #[test]
967    fn should_squash_respects_no_squash_and_config() {
968        assert!(should_squash_land(false, true));
969        assert!(!should_squash_land(true, true));
970        assert!(!should_squash_land(false, false));
971    }
972
973    #[test]
974    fn land_checkpoint_message_precedence() {
975        assert_eq!(
976            land_checkpoint_message(Some("explicit"), false, "t", Some("intent"), Some("task")),
977            "explicit"
978        );
979        assert_eq!(
980            land_checkpoint_message(Some("  "), true, "t", Some("intent"), None),
981            ""
982        );
983        assert_eq!(
984            land_checkpoint_message(None, false, "t", Some("intent"), Some("task")),
985            "intent"
986        );
987        assert_eq!(
988            land_checkpoint_message(None, false, "t", None, Some("task")),
989            "task"
990        );
991        assert_eq!(land_checkpoint_message(None, false, "t", None, None), "t");
992    }
993
994    #[test]
995    fn land_checkpoint_subject_uses_task_instead_of_hex_thread_id() {
996        let id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
997        let subject = land_checkpoint_message(
998            None,
999            true,
1000            id,
1001            Some("First capture intent"),
1002            Some("Improve search results"),
1003        );
1004        assert_eq!(subject, "Improve search results");
1005        assert!(!subject.contains(id));
1006        assert_eq!(
1007            land_checkpoint_message(None, true, id, Some("Add search index"), None),
1008            "Add search index"
1009        );
1010    }
1011
1012    #[test]
1013    fn land_checkpoint_honors_explicit_message_with_64_hex_identity() {
1014        let id = "a".repeat(64);
1015        let explicit = format!("Revert {id}");
1016        let subject = land_checkpoint_message(
1017            Some(&explicit),
1018            true,
1019            "search",
1020            None,
1021            Some("Improve search results"),
1022        );
1023        assert_eq!(subject.lines().next(), Some(explicit.as_str()));
1024        assert_eq!(subject, explicit);
1025    }
1026
1027    #[test]
1028    fn land_checkpoint_long_task_keeps_subject_short_and_detail_in_body() {
1029        let task = "Improve search ranking so results from multiple sources stay relevant and readable for people";
1030        let message = land_checkpoint_message(None, true, "feature/search", None, Some(task));
1031        let subject = message.lines().next().unwrap_or_default();
1032        assert!(subject.chars().count() <= 72, "{message}");
1033        assert!(message.contains(&format!("\n\n{task}")), "{message}");
1034    }
1035
1036    #[test]
1037    fn state_id_matches_short_or_full() {
1038        assert!(state_id_matches_display("abc", "abcdef", "abc"));
1039        assert!(state_id_matches_display("abc", "abcdef", "abcdef"));
1040        assert!(!state_id_matches_display("abc", "abcdef", "zzz"));
1041    }
1042
1043    #[test]
1044    fn land_text_scope_and_manual_review() {
1045        assert_eq!(land_text_step("capture"), "saved");
1046        assert_eq!(land_text_step("merge(blocked)"), "merge blocked");
1047        assert_eq!(
1048            land_text_step("merge(already_integrated)"),
1049            "already landed"
1050        );
1051        assert_eq!(
1052            land_text_step("checkpoint(not reached)"),
1053            "Git commit skipped because merge did not run"
1054        );
1055        assert!(is_manual_review_blocker("Heavy-impact change: Cargo.lock"));
1056        assert!(!is_manual_review_blocker("stale"));
1057        assert_eq!(
1058            scope_action_to_repo("heddle land main", "/tmp/repo"),
1059            "heddle --repo /tmp/repo land main"
1060        );
1061        assert_eq!(ready_merge_type_label("fast_forward"), "fast-forward");
1062        assert_eq!(
1063            ready_merge_type_label("already_integrated"),
1064            "already integrated"
1065        );
1066        assert_eq!(ready_merge_type_label("no_target"), "none configured");
1067    }
1068}