use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tip {
CheckpointAfterCapture,
QueryFromLog,
AgentServeForLatency,
ConflictForStructured,
}
impl Tip {
pub fn key(&self) -> &'static str {
match self {
Self::CheckpointAfterCapture => "checkpoint_after_capture",
Self::QueryFromLog => "query_from_log",
Self::AgentServeForLatency => "agent_serve_for_latency",
Self::ConflictForStructured => "conflict_for_structured",
}
}
pub fn message(&self) -> &'static str {
match self {
Self::CheckpointAfterCapture => {
"tip: `heddle checkpoint` is cheaper than `capture` for frequent agent-style saves"
}
Self::QueryFromLog => {
"tip: `heddle query` filters across the operation log (see `heddle help operation-ids`)"
}
Self::AgentServeForLatency => {
"tip: `heddle agent serve` runs a local daemon that cuts per-command latency for agent loops"
}
Self::ConflictForStructured => {
"tip: `heddle conflict show` returns conflicts as structured data agents can resolve programmatically"
}
}
}
}
pub fn session_marker_dir(repo_root: &std::path::Path) -> PathBuf {
let canonical = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
let hash = blake3::hash(canonical.to_string_lossy().as_bytes());
let id = hex::encode(&hash.as_bytes()[..8]);
let home = dirs_home().unwrap_or_else(|| PathBuf::from("/tmp"));
home.join(".heddle").join("session").join(id)
}
fn dirs_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn marker_file(repo_root: &std::path::Path) -> PathBuf {
session_marker_dir(repo_root).join("tips-shown.toml")
}
fn already_shown(repo_root: &std::path::Path, tip: Tip) -> bool {
already_shown_at(&marker_file(repo_root), tip)
}
fn record_shown(repo_root: &std::path::Path, tip: Tip) -> std::io::Result<()> {
record_shown_at(&marker_file(repo_root), tip)
}
fn already_shown_at(path: &std::path::Path, tip: Tip) -> bool {
let Ok(raw) = std::fs::read_to_string(path) else {
return false;
};
raw.lines()
.any(|line| line.split_whitespace().next() == Some(tip.key()))
}
fn record_shown_at(path: &std::path::Path, tip: Tip) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
use std::io::Write;
let line = format!("{} {}\n", tip.key(), unix_secs());
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
file.write_all(line.as_bytes())
}
fn unix_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn maybe_emit(
repo_root: &std::path::Path,
cfg: Option<&repo::RepoConfig>,
tip: Tip,
as_json: bool,
) {
if as_json {
return;
}
if let Some(cfg) = cfg
&& !cfg_tips_enabled(cfg)
{
return;
}
if let Some(cfg) = cfg
&& cfg_tip_suppressed(cfg, tip)
{
return;
}
if already_shown(repo_root, tip) {
return;
}
eprintln!("{}", tip.message());
let _ = record_shown(repo_root, tip);
}
fn cfg_tips_enabled(_cfg: &repo::RepoConfig) -> bool {
true
}
fn cfg_tip_suppressed(_cfg: &repo::RepoConfig, _tip: Tip) -> bool {
false
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn tip_keys_are_unique_and_stable() {
let keys = [
Tip::CheckpointAfterCapture.key(),
Tip::QueryFromLog.key(),
Tip::AgentServeForLatency.key(),
Tip::ConflictForStructured.key(),
];
let unique: std::collections::HashSet<_> = keys.iter().collect();
assert_eq!(unique.len(), keys.len(), "duplicate tip keys");
}
#[test]
fn already_shown_after_record_at_path() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("tips-shown.toml");
assert!(!already_shown_at(&path, Tip::CheckpointAfterCapture));
record_shown_at(&path, Tip::CheckpointAfterCapture).unwrap();
assert!(already_shown_at(&path, Tip::CheckpointAfterCapture));
}
#[test]
fn record_shown_at_appends_distinct_tips() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("tips-shown.toml");
record_shown_at(&path, Tip::CheckpointAfterCapture).unwrap();
record_shown_at(&path, Tip::QueryFromLog).unwrap();
assert!(already_shown_at(&path, Tip::CheckpointAfterCapture));
assert!(already_shown_at(&path, Tip::QueryFromLog));
assert!(!already_shown_at(&path, Tip::AgentServeForLatency));
}
#[test]
fn already_shown_at_missing_file_is_not_shown() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("does-not-exist.toml");
assert!(!already_shown_at(&path, Tip::CheckpointAfterCapture));
}
#[test]
fn maybe_emit_is_noop_in_json_mode() {
let temp = TempDir::new().unwrap();
maybe_emit(temp.path(), None, Tip::CheckpointAfterCapture, true);
}
}