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