heddle_cli_render/cli/
tips.rs1use std::path::PathBuf;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Tip {
21 QueryFromLog,
24 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
47pub 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
60fn 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
72fn 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
83fn 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
104pub 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 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 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 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 let temp = TempDir::new().unwrap();
210 maybe_emit(temp.path(), None, Tip::QueryFromLog, false, true);
211 }
212}