heddle_cli_render/cli/
tips.rs1use std::path::PathBuf;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Tip {
21 CheckpointAfterCapture,
24 QueryFromLog,
27 AgentServeForLatency,
30 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
61pub 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
79fn 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
91fn 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
102fn 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
123pub 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 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 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 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 let temp = TempDir::new().unwrap();
228 maybe_emit(temp.path(), None, Tip::CheckpointAfterCapture, false, true);
229 }
230}