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