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 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
54pub 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
67fn 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
79fn 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
90fn 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
111pub 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 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 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 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 let temp = TempDir::new().unwrap();
215 maybe_emit(temp.path(), None, Tip::CheckpointAfterCapture, false, true);
216 }
217}