Skip to main content

ito_core/
harness_context.rs

1//! Harness context inference helpers.
2//!
3//! These helpers infer an Ito target (change or module) from local signals so
4//! harness hooks/plugins can recover context after compaction.
5//!
6//! Inference priority:
7//!
8//! 1. Current working directory path segments.
9//! 2. Current git branch name (if available).
10//!
11//! When multiple change-id-like path segments are present, the last match wins.
12
13use crate::errors::CoreResult;
14use ito_common::id::{parse_change_id, parse_module_id};
15use std::path::{Component, Path};
16
17/// Kind of Ito target inferred for a harness session.
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum InferredItoTargetKind {
21    /// A change id (e.g. `023-07_harness-context-inference`).
22    Change,
23    /// A module id (e.g. `023`).
24    Module,
25}
26
27/// Inferred Ito target (change or module).
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
29pub struct InferredItoTarget {
30    /// Target kind.
31    pub kind: InferredItoTargetKind,
32    /// Canonical identifier string.
33    pub id: String,
34}
35
36/// Inference result suitable for harness hooks and plugins.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38pub struct HarnessContextInference {
39    /// Inferred Ito target (or none when inference is inconclusive).
40    pub target: Option<InferredItoTarget>,
41    /// Continuation guidance appropriate to the inferred target.
42    pub nudge: String,
43}
44
45/// Infer the current Ito target for a harness session.
46///
47/// This is a best-effort, deterministic inference based on local signals:
48///
49/// 1. Current working directory path segments.
50/// 2. Current git branch name (if `git` is available).
51pub fn infer_context_from_cwd(cwd: &Path) -> CoreResult<HarnessContextInference> {
52    let target = infer_target_from_path(cwd).or_else(|| infer_target_from_git_branch(cwd));
53    let nudge = nudge_for_target(target.as_ref());
54    Ok(HarnessContextInference { target, nudge })
55}
56
57fn nudge_for_target(target: Option<&InferredItoTarget>) -> String {
58    let Some(target) = target else {
59        return "No Ito change/module inferred. Re-establish the target (try: `ito list --ready`, then `ito tasks next <change-id>`).".to_string();
60    };
61
62    match target.kind {
63        InferredItoTargetKind::Change => format!(
64            "If you are still implementing change {id}, continue now: `ito tasks next {id}` (then `ito tasks start/complete` as you progress).",
65            id = target.id
66        ),
67        InferredItoTargetKind::Module => format!(
68            "If you are still working in module {id}, continue now: `ito show module {id}` then run `ito tasks next <change-id>` for the next ready change.",
69            id = target.id
70        ),
71    }
72}
73
74fn infer_target_from_path(cwd: &Path) -> Option<InferredItoTarget> {
75    let mut change: Option<String> = None;
76    let mut module: Option<String> = None;
77
78    let mut last_was_ito_dir = false;
79    let mut expect_module_dir = false;
80
81    for component in cwd.components() {
82        let Component::Normal(seg) = component else {
83            continue;
84        };
85        let Some(seg) = seg.to_str() else {
86            last_was_ito_dir = false;
87            expect_module_dir = false;
88            continue;
89        };
90
91        if expect_module_dir {
92            if let Ok(parsed) = parse_module_id(seg) {
93                module = Some(parsed.module_id.as_str().to_string());
94            }
95            expect_module_dir = false;
96        }
97
98        if let Ok(parsed) = parse_change_id(seg) {
99            change = Some(parsed.canonical.as_str().to_string());
100        }
101
102        if last_was_ito_dir && seg == "modules" {
103            expect_module_dir = true;
104        }
105
106        last_was_ito_dir = seg == ".ito";
107    }
108
109    if let Some(id) = change {
110        return Some(InferredItoTarget {
111            kind: InferredItoTargetKind::Change,
112            id,
113        });
114    }
115
116    let id = module?;
117    Some(InferredItoTarget {
118        kind: InferredItoTargetKind::Module,
119        id,
120    })
121}
122
123fn infer_target_from_git_branch(cwd: &Path) -> Option<InferredItoTarget> {
124    let branch = git_output(cwd, &["branch", "--show-current"])?
125        .trim()
126        .to_string();
127    if branch.is_empty() {
128        return None;
129    }
130
131    if let Ok(parsed) = parse_change_id(&branch) {
132        return Some(InferredItoTarget {
133            kind: InferredItoTargetKind::Change,
134            id: parsed.canonical.as_str().to_string(),
135        });
136    }
137
138    if let Ok(parsed) = parse_module_id(&branch) {
139        return Some(InferredItoTarget {
140            kind: InferredItoTargetKind::Module,
141            id: parsed.module_id.as_str().to_string(),
142        });
143    }
144
145    None
146}
147
148fn git_output(cwd: &Path, args: &[&str]) -> Option<String> {
149    let mut command = std::process::Command::new("git");
150    command.args(args).current_dir(cwd);
151
152    // Ignore injected git environment variables to avoid surprising repository selection.
153    for (k, _) in std::env::vars_os() {
154        let k = k.to_string_lossy();
155        if k.starts_with("GIT_") {
156            command.env_remove(k.as_ref());
157        }
158    }
159
160    let output = command.output().ok()?;
161    if !output.status.success() {
162        return None;
163    }
164    Some(String::from_utf8_lossy(&output.stdout).to_string())
165}