use crate::core::session::Session;
use crate::core::tokens;
use crate::core::tracker::{self, FallbackReason, ReadOutcome, RegistryStatus};
use anyhow::Result;
pub struct OutcomeSummary {
pub kind: &'static str,
pub fallback_reason: Option<String>,
pub tokens_full: i64,
pub tokens_sent: i64,
}
pub fn summarize(outcome: &ReadOutcome) -> OutcomeSummary {
match outcome {
ReadOutcome::FullFirst {
tokens,
compressed,
registry,
..
} => {
let body_tokens = compressed.as_ref().map(|c| c.tokens).unwrap_or(*tokens);
OutcomeSummary {
kind: if compressed.is_some() {
"first-compressed"
} else {
"first"
},
fallback_reason: None,
tokens_full: *tokens,
tokens_sent: body_tokens + tracker::registry_extra_tokens(registry),
}
}
ReadOutcome::Unchanged { tokens_full } => OutcomeSummary {
kind: "unchanged",
fallback_reason: None,
tokens_full: *tokens_full,
tokens_sent: 0,
},
ReadOutcome::Delta {
tokens_full,
tokens_sent,
..
} => OutcomeSummary {
kind: "delta",
fallback_reason: None,
tokens_full: *tokens_full,
tokens_sent: *tokens_sent,
},
ReadOutcome::FullFallback { reason, tokens, .. } => OutcomeSummary {
kind: "fallback",
fallback_reason: Some(reason.label()),
tokens_full: *tokens,
tokens_sent: *tokens,
},
ReadOutcome::Deleted => OutcomeSummary {
kind: "deleted",
fallback_reason: None,
tokens_full: 0,
tokens_sent: 0,
},
ReadOutcome::Passthrough => OutcomeSummary {
kind: "passthrough",
fallback_reason: None,
tokens_full: 0,
tokens_sent: 0,
},
ReadOutcome::EditCertificate {
tokens_full,
tokens_sent,
..
} => OutcomeSummary {
kind: "edit-cert",
fallback_reason: None,
tokens_full: *tokens_full,
tokens_sent: *tokens_sent,
},
ReadOutcome::WindowUnchanged {
tokens_full_window, ..
} => OutcomeSummary {
kind: "partial-unchanged",
fallback_reason: None,
tokens_full: *tokens_full_window,
tokens_sent: 0,
},
ReadOutcome::WindowDelta {
tokens_full_window,
tokens_sent,
..
} => OutcomeSummary {
kind: "partial-delta",
fallback_reason: None,
tokens_full: *tokens_full_window,
tokens_sent: *tokens_sent,
},
}
}
#[derive(Debug, Default)]
pub struct SessionDecoration {
pub expired_resumed: bool,
pub ttl_warning: Option<String>,
pub compaction_count: i64,
}
pub fn render_and_record(session: &Session, file_path: &str, outcome: ReadOutcome) -> String {
let deco = build_session_decoration(session);
let summary = summarize(&outcome);
let rendered = render_with_session(file_path, outcome, &deco);
if deco.expired_resumed {
session.was_expired.set(false);
}
let canonical = tracker::canonical_key(std::path::Path::new(file_path));
let rendered_tokens = tokens::estimate(&rendered);
let count_header = !matches!(summary.kind, "fallback");
let final_tokens_sent = if count_header {
rendered_tokens
} else {
summary.tokens_sent
};
let header_overhead = (final_tokens_sent - summary.tokens_sent).max(0);
if header_overhead > 0 {
let _ = session.bump_lifetime_overhead(&canonical, header_overhead);
}
let _ = session.record_event(
&canonical,
summary.kind,
summary.fallback_reason.as_deref(),
summary.tokens_full,
final_tokens_sent,
&rendered,
);
rendered
}
pub fn build_session_decoration(session: &Session) -> SessionDecoration {
let expired_resumed = session.was_expired.get();
let ttl_warning = session
.seconds_until_expiry()
.filter(|remaining| {
*remaining < crate::core::session::session_ttl_secs() / 10
})
.map(|remaining| {
format!(
"⏱ session expires in {} — run `drip reset` to start fresh",
format_duration(remaining)
)
});
let compaction_count = session
.compaction_state()
.ok()
.flatten()
.map(|c| c.count)
.unwrap_or(0);
SessionDecoration {
expired_resumed,
ttl_warning,
compaction_count,
}
}
pub fn build_session_segment(deco: &SessionDecoration, on_first_read: bool) -> String {
let mut out = String::new();
if on_first_read {
if deco.compaction_count > 0 {
out.push_str(&format!(
" | ↺ context was compacted (#{}) — baseline reset",
deco.compaction_count
));
} else if deco.expired_resumed {
out.push_str(" | ℹ session expired — fresh baseline started");
}
}
if let Some(w) = &deco.ttl_warning {
out.push_str(" | ");
out.push_str(w);
}
out
}
fn format_duration(secs: i64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{} min", secs / 60)
} else {
format!("{}h{:02}", secs / 3600, (secs % 3600) / 60)
}
}
pub fn run(file_path: &str) -> Result<String> {
run_with(file_path, false)
}
pub fn run_with(file_path: &str, dry_run: bool) -> Result<String> {
let session = Session::open()?;
let outcome = if dry_run {
tracker::process_read_dry(&session, file_path)?
} else {
tracker::process_read(&session, file_path)?
};
let body = if dry_run {
render(file_path, outcome)
} else {
render_and_record(&session, file_path, outcome)
};
if dry_run {
Ok(format!("[DRIP: dry-run, no state mutated]\n{body}"))
} else {
Ok(body)
}
}
pub fn render(file_path: &str, outcome: ReadOutcome) -> String {
render_with_session(file_path, outcome, &SessionDecoration::default())
}
pub fn render_unchanged(file_path: &str, tokens_full: i64, session_seg: &str) -> String {
if tokens_full == 0 {
format!("[DRIP: unchanged (empty file) | {file_path}]\n")
} else {
format!(
"[DRIP: unchanged since last read | 0 tokens sent ({tokens_full} saved){session_seg} | {file_path}]\n"
)
}
}
pub fn render_window_unchanged(
file_path: &str,
start_line: usize,
end_line: usize,
tokens_full_window: i64,
) -> String {
format!(
"[DRIP: unchanged (lines {start_line}-{end_line}) | 0 tokens sent ({tokens_full_window} saved) | {file_path}]\n"
)
}
pub fn render_delta(
file_path: &str,
tokens_full: i64,
tokens_sent: i64,
hunk_summary: Option<&[(usize, Option<String>)]>,
session_seg: &str,
diff: &str,
) -> String {
let pct = tokens::percent_saved(tokens_full, tokens_sent);
let summary_seg = build_hunk_summary_segment(hunk_summary);
let header = format!(
"[DRIP: delta only | {pct}% token reduction ({tokens_sent}/{tokens_full}){summary_seg}{session_seg} | {file_path}]\n"
);
format!("{header}{diff}")
}
pub fn render_window_delta(
file_path: &str,
start_line: usize,
end_line: usize,
tokens_full_window: i64,
tokens_sent: i64,
diff: &str,
) -> String {
let pct = tokens::percent_saved(tokens_full_window, tokens_sent);
let header = format!(
"[DRIP: delta only (lines {start_line}-{end_line}) | {pct}% token reduction ({tokens_sent}/{tokens_full_window}) | {file_path}]\n"
);
format!("{header}{diff}")
}
pub fn render_edit_certificate(
file_path: &str,
after_hash: &str,
touched_ranges: &[(usize, usize)],
touched_symbols: &[String],
total_lines: usize,
tokens_full: i64,
tokens_sent: i64,
) -> String {
let pct = tokens::percent_saved(tokens_full, tokens_sent);
let short_hash: String = after_hash.chars().take(12).collect();
let body = tracker::edit_certificate_body(
file_path,
after_hash,
touched_ranges,
touched_symbols,
total_lines,
);
format!(
"[DRIP: edit verified | {pct}% reduction ({tokens_sent}/{tokens_full} tokens) | hash: {short_hash} | {file_path}]\n{body}"
)
}
fn build_hunk_summary_segment(hunk_summary: Option<&[(usize, Option<String>)]>) -> String {
hunk_summary
.map(|h| {
let parts: Vec<String> = h
.iter()
.take(6) .map(|(ln, name)| match name {
Some(n) => format!("{n} (ln {ln})"),
None => format!("ln {ln}"),
})
.collect();
let extra = h.len().saturating_sub(6);
let body = parts.join(", ");
if extra > 0 {
format!(" | {} hunks: {body}, +{extra} more", h.len())
} else {
format!(" | {} hunks: {body}", h.len())
}
})
.unwrap_or_default()
}
pub fn render_with_session(
file_path: &str,
outcome: ReadOutcome,
deco: &SessionDecoration,
) -> String {
let session_seg = build_session_segment(
deco,
matches!(&outcome, ReadOutcome::FullFirst { .. }),
);
match outcome {
ReadOutcome::FullFirst {
content,
tokens,
compressed,
registry,
} => {
let (registry_segment, registry_trailer) = render_registry(®istry);
match compressed {
Some(c) => {
let sent = c.tokens + tracker::registry_extra_tokens(®istry);
let pct = tokens::percent_saved(tokens, sent);
let header = format!(
"[DRIP: full read (semantic-compressed) | {pct}% reduction \
({sent}/{full} tokens) | {funcs} functions elided, \
{lines} lines hidden{reg}{ses} | run `drip refresh` for full content | {file_path}]\n",
sent = sent,
full = tokens,
funcs = c.functions_elided,
lines = c.lines_elided,
reg = registry_segment,
ses = session_seg,
);
format!("{header}{}{registry_trailer}", c.text)
}
None if content.is_empty() => {
format!("[DRIP: empty file | {file_path}]\n")
}
None => {
let header = format!(
"[DRIP: full read | {tokens} tokens{registry_segment}{session_seg} | {file_path}]\n"
);
format!("{header}{content}{registry_trailer}")
}
}
}
ReadOutcome::Unchanged { tokens_full } => {
render_unchanged(file_path, tokens_full, &session_seg)
}
ReadOutcome::Delta {
diff,
tokens_full,
tokens_sent,
hunk_summary,
} => render_delta(
file_path,
tokens_full,
tokens_sent,
hunk_summary.as_deref(),
&session_seg,
&diff,
),
ReadOutcome::FullFallback {
content,
reason,
tokens,
} => {
let label = reason.label();
match reason {
FallbackReason::DripOverheadBiggerThanFile => content,
FallbackReason::Binary | FallbackReason::NonUtf8 => {
format!("[DRIP: {label} | {tokens} tokens | {file_path}]\n{content}\n")
}
_ => {
let header = format!("[DRIP: {label} | {tokens} tokens | {file_path}]\n");
format!("{header}{content}")
}
}
}
ReadOutcome::Deleted => {
format!("[DRIP: file deleted since last read | {file_path}]\n")
}
ReadOutcome::Passthrough => {
format!(
"[DRIP: post-edit passthrough | next read after this is normal | {file_path}]\n"
)
}
ReadOutcome::EditCertificate {
after_hash,
touched_ranges,
touched_symbols,
total_lines,
tokens_full,
tokens_sent,
..
} => render_edit_certificate(
file_path,
&after_hash,
&touched_ranges,
&touched_symbols,
total_lines,
tokens_full,
tokens_sent,
),
ReadOutcome::WindowUnchanged {
start_line,
end_line,
tokens_full_window,
} => render_window_unchanged(file_path, start_line, end_line, tokens_full_window),
ReadOutcome::WindowDelta {
diff,
start_line,
end_line,
tokens_full_window,
tokens_sent,
} => render_window_delta(
file_path,
start_line,
end_line,
tokens_full_window,
tokens_sent,
&diff,
),
}
}
fn render_registry(status: &RegistryStatus) -> (String, String) {
match status {
RegistryStatus::Unknown => (String::new(), String::new()),
RegistryStatus::Unchanged {
last_seen_secs_ago,
last_git_branch,
} => {
let age = format_age(*last_seen_secs_ago);
let branch = match last_git_branch {
Some(b) => format!(", branch: {b}"),
None => String::new(),
};
(
format!(" | ↔ unchanged since last session ({age}{branch})"),
String::new(),
)
}
RegistryStatus::Changed {
last_seen_secs_ago,
last_git_branch,
added_lines,
removed_lines,
diff_text,
} => {
let age = format_age(*last_seen_secs_ago);
let branch = match last_git_branch {
Some(b) => format!(", branch was: {b}"),
None => String::new(),
};
let segment = format!(
" | ↕ changed since last session ({age}): +{added_lines} lines, -{removed_lines} lines{branch}"
);
let trailer = format!(
"\n\n── Changes since last session ──────────────────────────────\n\
{diff_text}\n\
────────────────────────────────────────────────────────────\n"
);
(segment, trailer)
}
}
}
fn format_age(secs: i64) -> String {
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86_400)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::tracker::ReadOutcome;
fn no_deco() -> SessionDecoration {
SessionDecoration::default()
}
#[test]
fn render_with_session_unchanged_matches_helper() {
let out = render_with_session(
"/p.txt",
ReadOutcome::Unchanged { tokens_full: 800 },
&no_deco(),
);
let expected = render_unchanged("/p.txt", 800, "");
assert_eq!(out, expected);
}
#[test]
fn render_with_session_unchanged_empty_file_matches_helper() {
let out = render_with_session(
"/empty.txt",
ReadOutcome::Unchanged { tokens_full: 0 },
&no_deco(),
);
let expected = render_unchanged("/empty.txt", 0, "");
assert_eq!(out, expected);
}
#[test]
fn render_with_session_unchanged_carries_ttl_warning() {
let deco = SessionDecoration {
ttl_warning: Some("⏱ session expires in 5 min — run `drip reset`".into()),
..SessionDecoration::default()
};
let out = render_with_session("/p.txt", ReadOutcome::Unchanged { tokens_full: 800 }, &deco);
let seg = build_session_segment(&deco, false);
let expected = render_unchanged("/p.txt", 800, &seg);
assert_eq!(out, expected);
assert!(
out.contains("⏱ session expires"),
"TTL warning must surface: {out}"
);
}
#[test]
fn render_with_session_delta_matches_helper() {
let diff = "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new\n".to_string();
let summary = vec![(10usize, Some("fn foo".to_string()))];
let outcome = ReadOutcome::Delta {
diff: diff.clone(),
tokens_full: 1000,
tokens_sent: 50,
hunk_summary: Some(summary.clone()),
};
let out = render_with_session("/p.rs", outcome, &no_deco());
let expected = render_delta("/p.rs", 1000, 50, Some(&summary), "", &diff);
assert_eq!(out, expected);
}
#[test]
fn render_with_session_delta_no_summary_matches_helper() {
let diff = "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new\n".to_string();
let outcome = ReadOutcome::Delta {
diff: diff.clone(),
tokens_full: 1000,
tokens_sent: 50,
hunk_summary: None,
};
let out = render_with_session("/p.rs", outcome, &no_deco());
let expected = render_delta("/p.rs", 1000, 50, None, "", &diff);
assert_eq!(out, expected);
}
#[test]
fn render_with_session_window_unchanged_matches_helper() {
let outcome = ReadOutcome::WindowUnchanged {
start_line: 10,
end_line: 30,
tokens_full_window: 200,
};
let out = render_with_session("/p.txt", outcome, &no_deco());
let expected = render_window_unchanged("/p.txt", 10, 30, 200);
assert_eq!(out, expected);
}
#[test]
fn render_with_session_window_delta_matches_helper() {
let diff = "--- a\n+++ b\n@@ -10,3 +10,3 @@\n line 10\n-line 11\n+line 11 new\n line 12\n"
.to_string();
let outcome = ReadOutcome::WindowDelta {
diff: diff.clone(),
start_line: 10,
end_line: 30,
tokens_full_window: 200,
tokens_sent: 25,
};
let out = render_with_session("/p.txt", outcome, &no_deco());
let expected = render_window_delta("/p.txt", 10, 30, 200, 25, &diff);
assert_eq!(out, expected);
}
#[test]
fn render_with_session_edit_certificate_matches_helper() {
let outcome = ReadOutcome::EditCertificate {
before_hash: "deadbeef".into(),
after_hash: "abcdef1234567890".into(),
touched_ranges: vec![(10, 12)],
touched_symbols: vec!["fn bar".into()],
total_lines: 200,
tokens_full: 500,
tokens_sent: 40,
};
let out = render_with_session("/p.rs", outcome, &no_deco());
let expected = render_edit_certificate(
"/p.rs",
"abcdef1234567890",
&[(10, 12)],
&["fn bar".into()],
200,
500,
40,
);
assert_eq!(out, expected);
}
}