Skip to main content

heddle_cli_render/cli/
tips.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Discoverability tips (A17).
3//!
4//! After a successful verb, the CLI may emit a one-line tip nudging the
5//! user toward a more powerful affordance. Tips are:
6//!
7//! - **stderr only**: piping `heddle <verb>` to other tools never includes
8//!   tips.
9//! - **never in `--output json`**: scripted consumers don't get advisory output.
10//! - **once per session per repo**: a session marker file at
11//!   `~/.heddle/session/<repo-id>/tips-shown.toml` records which tips
12//!   have been shown so we don't nag.
13//! - **per-repo permanently suppressible** via `[ui.tips] enabled = false`
14//!   in `.heddle/config.toml` (or `[ui.tips.suppress] keys = [...]` to
15//!   suppress individual tips).
16
17use std::path::PathBuf;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Tip {
21    /// "tip: Git Overlay source history stays in direct Git commands."
22    /// Emitted after a successful capture.
23    CheckpointAfterCapture,
24    /// "tip: `heddle query` searches saved change history."
25    /// Emitted after the first heavy `heddle log` view.
26    QueryFromLog,
27    /// "tip: `heddle agent serve` runs a local daemon for tight loops."
28    /// Emitted after the first state-changing verb in a fresh shell.
29    AgentServeForLatency,
30    /// "tip: `heddle resolve --output json` returns conflicts as structured data."
31    /// Emitted on a conflicted merge.
32    ConflictForStructured,
33}
34
35impl Tip {
36    pub fn key(&self) -> &'static str {
37        match self {
38            Self::CheckpointAfterCapture => "checkpoint_after_capture",
39            Self::QueryFromLog => "query_from_log",
40            Self::AgentServeForLatency => "agent_serve_for_latency",
41            Self::ConflictForStructured => "conflict_for_structured",
42        }
43    }
44
45    pub fn message(&self) -> &'static str {
46        match self {
47            Self::CheckpointAfterCapture => {
48                "tip: in Git Overlay, run `heddle commit` when the captured state is ready"
49            }
50            Self::QueryFromLog => "tip: `heddle query` searches saved change history",
51            Self::AgentServeForLatency => {
52                "tip: `heddle agent serve` runs a local daemon that cuts per-command latency for agent loops"
53            }
54            Self::ConflictForStructured => {
55                "tip: `heddle resolve --output json` returns conflicts as structured data agents can resolve programmatically"
56            }
57        }
58    }
59}
60
61/// Identify the per-repo session marker directory. Hashes the canonical
62/// repo root path so distinct worktrees of the same repo don't collide.
63pub fn session_marker_dir(repo_root: &std::path::Path) -> PathBuf {
64    let canonical = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
65    let hash = blake3::hash(canonical.to_string_lossy().as_bytes());
66    let id = hex::encode(&hash.as_bytes()[..8]);
67    let home = dirs_home().unwrap_or_else(|| PathBuf::from("/tmp"));
68    home.join(".heddle").join("session").join(id)
69}
70
71fn dirs_home() -> Option<PathBuf> {
72    std::env::var_os("HOME").map(PathBuf::from)
73}
74
75fn marker_file(repo_root: &std::path::Path) -> PathBuf {
76    session_marker_dir(repo_root).join("tips-shown.toml")
77}
78
79/// Returns true if the tip has already been shown for this repo+session.
80///
81/// Resolves the marker path via `HOME`. The path-taking variant is
82/// [`already_shown_at`]; tests use that to avoid touching process env.
83fn already_shown(repo_root: &std::path::Path, tip: Tip) -> bool {
84    already_shown_at(&marker_file(repo_root), tip)
85}
86
87fn record_shown(repo_root: &std::path::Path, tip: Tip) -> std::io::Result<()> {
88    record_shown_at(&marker_file(repo_root), tip)
89}
90
91/// Path-taking primitive: read the marker file at `path` and check
92/// whether `tip`'s key appears. Returns `false` when the file is
93/// missing or unreadable. Pure I/O — no env access.
94fn already_shown_at(path: &std::path::Path, tip: Tip) -> bool {
95    let Ok(raw) = std::fs::read_to_string(path) else {
96        return false;
97    };
98    raw.lines()
99        .any(|line| line.split_whitespace().next() == Some(tip.key()))
100}
101
102/// Path-taking primitive: append `tip` to the marker file at `path`.
103fn record_shown_at(path: &std::path::Path, tip: Tip) -> std::io::Result<()> {
104    if let Some(parent) = path.parent() {
105        std::fs::create_dir_all(parent)?;
106    }
107    use std::io::Write;
108    let line = format!("{} {}\n", tip.key(), unix_secs());
109    let mut file = std::fs::OpenOptions::new()
110        .create(true)
111        .append(true)
112        .open(path)?;
113    file.write_all(line.as_bytes())
114}
115
116fn unix_secs() -> i64 {
117    std::time::SystemTime::now()
118        .duration_since(std::time::UNIX_EPOCH)
119        .map(|d| d.as_secs() as i64)
120        .unwrap_or(0)
121}
122
123/// Emit a tip on stderr if it hasn't been shown yet for this repo and the
124/// caller hasn't suppressed tips. The `as_json` argument should be `true`
125/// when the verb is rendering JSON — tips are skipped there to keep
126/// scripted output clean. The `quiet` argument reflects the global
127/// `--quiet` flag and suppresses nonessential discoverability copy.
128pub fn maybe_emit(
129    repo_root: &std::path::Path,
130    cfg: Option<&repo::RepoConfig>,
131    tip: Tip,
132    as_json: bool,
133    quiet: bool,
134) {
135    if as_json || quiet {
136        return;
137    }
138    if let Some(cfg) = cfg
139        && !cfg_tips_enabled(cfg)
140    {
141        return;
142    }
143    if let Some(cfg) = cfg
144        && cfg_tip_suppressed(cfg, tip)
145    {
146        return;
147    }
148    if already_shown(repo_root, tip) {
149        return;
150    }
151    eprintln!("{}", tip.message());
152    let _ = record_shown(repo_root, tip);
153}
154
155fn cfg_tips_enabled(_cfg: &repo::RepoConfig) -> bool {
156    // The repo config doesn't yet carry a `[ui.tips]` section. When we
157    // add it (W2 follow-up), wire `cfg.ui.tips.enabled` here. For now,
158    // tips are on by default with the per-tip session-marker check
159    // providing the not-too-noisy bound.
160    true
161}
162
163fn cfg_tip_suppressed(_cfg: &repo::RepoConfig, _tip: Tip) -> bool {
164    false
165}
166
167#[cfg(test)]
168mod tests {
169    use tempfile::TempDir;
170
171    use super::*;
172
173    #[test]
174    fn tip_keys_are_unique_and_stable() {
175        let keys = [
176            Tip::CheckpointAfterCapture.key(),
177            Tip::QueryFromLog.key(),
178            Tip::AgentServeForLatency.key(),
179            Tip::ConflictForStructured.key(),
180        ];
181        let unique: std::collections::HashSet<_> = keys.iter().collect();
182        assert_eq!(unique.len(), keys.len(), "duplicate tip keys");
183    }
184
185    #[test]
186    fn already_shown_after_record_at_path() {
187        // Test the path-taking primitives directly. The env-resolving
188        // wrappers (`already_shown` / `record_shown`) read `HOME`,
189        // which is process-global and races with parallel tests; this
190        // covers the same logic without touching the environment.
191        let temp = TempDir::new().unwrap();
192        let path = temp.path().join("tips-shown.toml");
193        assert!(!already_shown_at(&path, Tip::CheckpointAfterCapture));
194        record_shown_at(&path, Tip::CheckpointAfterCapture).unwrap();
195        assert!(already_shown_at(&path, Tip::CheckpointAfterCapture));
196    }
197
198    #[test]
199    fn record_shown_at_appends_distinct_tips() {
200        let temp = TempDir::new().unwrap();
201        let path = temp.path().join("tips-shown.toml");
202        record_shown_at(&path, Tip::CheckpointAfterCapture).unwrap();
203        record_shown_at(&path, Tip::QueryFromLog).unwrap();
204        assert!(already_shown_at(&path, Tip::CheckpointAfterCapture));
205        assert!(already_shown_at(&path, Tip::QueryFromLog));
206        assert!(!already_shown_at(&path, Tip::AgentServeForLatency));
207    }
208
209    #[test]
210    fn already_shown_at_missing_file_is_not_shown() {
211        let temp = TempDir::new().unwrap();
212        let path = temp.path().join("does-not-exist.toml");
213        assert!(!already_shown_at(&path, Tip::CheckpointAfterCapture));
214    }
215
216    #[test]
217    fn maybe_emit_is_noop_in_json_mode() {
218        // Just exercises the gate — actual eprintln capture is fragile
219        // across platforms. This guards the early-return branch.
220        let temp = TempDir::new().unwrap();
221        maybe_emit(temp.path(), None, Tip::CheckpointAfterCapture, true, false);
222    }
223
224    #[test]
225    fn maybe_emit_is_noop_in_quiet_mode() {
226        // Same as JSON mode: quiet suppresses nonessential tips.
227        let temp = TempDir::new().unwrap();
228        maybe_emit(temp.path(), None, Tip::CheckpointAfterCapture, false, true);
229    }
230}