- 1
//! `vak inbox` (docs/design/29-personal-os.md P6): list/show/ack/count - 2
//! over `vak_core::inbox` (`<home>/inbox.jsonl`) without the server, the way - 3
//! `tasks`/`memory` read their stores directly. Row/footer/count rendering - 4
//! lives in pure helpers so tests assert strings instead of captured stdout. - 5
- 6
use std::path::{Path, PathBuf}; - 7
- 8
use vak_core::Core; - 9
use vak_core::inbox::{self, Entry, Kind}; - 10
- 11
const DEFAULT_LIMIT: usize = 50; - 12
const TITLE_WIDTH: usize = 48; - 13
- 14
pub(crate) fn run_inbox(cwd: PathBuf, action: Option<crate::cli::InboxAction>) -> i32 { - 15
let core = match Core::new(cwd) { - 16
Ok(c) => c, - 17
Err(e) => { - 18
eprintln!("error: {e}"); - 19
return 2; - 20
} - 21
}; - 22
run_with_home( - 23
&core.sessions_home(), - 24
action.unwrap_or(crate::cli::InboxAction::List { - 25
all: false, - 26
limit: DEFAULT_LIMIT, - 27
}), - 28
) - 29
} - 30
- 31
fn run_with_home(home: &Path, action: crate::cli::InboxAction) -> i32 { - 32
match action { - 33
crate::cli::InboxAction::List { all, limit } => { - 34
let limit = limit.clamp(1, inbox::MAX_SCAN); - 35
let rows = if all { - 36
all_rows(home, limit) - 37
} else { - 38
unread_rows(home, limit) - 39
}; - 40
if rows.is_empty() { - 41
println!( - 42
"{}", - 43
if all { - 44
"no inbox entries yet" - 45
} else { - 46
"no unread inbox entries" - 47
} - 48
); - 49
return 0; - 50
} - 51
for row in rows { - 52
println!("{row}"); - 53
} - 54
println!("{}", footer(home)); - 55
0 - 56
} - 57
crate::cli::InboxAction::Show { id_prefix } => match load_entry(home, &id_prefix) { - 58
Ok(entry) => { - 59
print_show(&entry); - 60
0 - 61
} - 62
Err(e) => { - 63
eprintln!("error: {e}"); - 64
1 - 65
} - 66
}, - 67
crate::cli::InboxAction::Ack { id_prefix } => match ack_prefix(home, &id_prefix) { - 68
Ok(true) => { - 69
println!("acked"); - 70
0 - 71
} - 72
Ok(false) => { - 73
println!("already acked"); - 74
0 - 75
} - 76
Err(e) => { - 77
eprintln!("error: {e}"); - 78
1 - 79
} - 80
}, - 81
crate::cli::InboxAction::Count => { - 82
println!("{}", count_line(home)); - 83
0 - 84
} - 85
} - 86
} - 87
- 88
fn ledger(home: &Path) -> Vec<Entry> { - 89
inbox::list(home, inbox::MAX_SCAN) - 90
} - 91
- 92
fn all_rows(home: &Path, limit: usize) -> Vec<String> { - 93
ledger(home) - 94
.into_iter() - 95
.take(limit) - 96
.map(|e| format_row(&e)) - 97
.collect() - 98
} - 99
- 100
fn unread_rows(home: &Path, limit: usize) -> Vec<String> { - 101
inbox::unread(home) - 102
.into_iter() - 103
.take(limit) - 104
.map(|e| format_row(&e)) - 105
.collect() - 106
} - 107
- 108
fn footer(home: &Path) -> String { - 109
format!( - 110
"{} unread of {} total", - 111
inbox::unread_count(home), - 112
ledger(home).len() - 113
) - 114
} - 115
- 116
fn count_line(home: &Path) -> String { - 117
format!("{} unread", inbox::unread_count(home)) - 118
} - 119
- 120
fn format_row(e: &Entry) -> String { - 121
format_row_rel(e, &rel_ts(e.ts)) - 122
} - 123
- 124
fn format_row_rel(e: &Entry, rel: &str) -> String { - 125
format!( - 126
"{:<8} {:<8} {:<width$} {}", - 127
rel, - 128
kind_chip(e.kind), - 129
truncate_title(&e.title), - 130
short_hex(&e.id), - 131
width = TITLE_WIDTH - 132
) - 133
} - 134
- 135
fn print_show(e: &Entry) { - 136
println!("kind {}", kind_chip(e.kind)); - 137
println!("when {} ({})", e.ts.to_rfc3339(), rel_ts(e.ts)); - 138
println!("id {}", e.id); - 139
println!("title {}", truncate_title(&e.title)); - 140
if !e.body.is_empty() { - 141
println!(); - 142
println!("{}", e.body); - 143
} - 144
if let Some(s) = &e.session_id { - 145
println!("session {s}"); - 146
} - 147
if let Some(t) = &e.task_id { - 148
println!("task {t}"); - 149
} - 150
} - 151
- 152
fn kind_chip(kind: Kind) -> &'static str { - 153
match kind { - 154
Kind::TaskSummary => "task", - 155
Kind::ApprovalPending => "approval", - 156
Kind::ApprovalDenied => "denied", - 157
Kind::BudgetAlert => "budget", - 158
Kind::Digest => "digest", - 159
Kind::Heartbeat => "beat", - 160
Kind::ProposalOpened => "proposal", - 161
Kind::RoutineFailed => "refused", - 162
} - 163
} - 164
- 165
fn truncate_title(title: &str) -> String { - 166
let flat = title.split_whitespace().collect::<Vec<_>>().join(" "); - 167
if flat.chars().count() <= TITLE_WIDTH { - 168
return flat; - 169
} - 170
let cut: String = flat.chars().take(TITLE_WIDTH - 1).collect(); - 171
format!("{cut}…") - 172
} - 173
- 174
/// First 8 chars of a hex id — display form only; ack runs on the full id. - 175
fn short_hex(hex: &str) -> String { - 176
hex.chars().take(8).collect() - 177
} - 178
- 179
/// Relative age in the house recall format: now / Nm ago / Nh ago / Nd ago. - 180
fn rel_ts(ts: chrono::DateTime<chrono::Utc>) -> String { - 181
let secs = (chrono::Utc::now() - ts).num_seconds().max(0) as u64; - 182
if secs < 60 { - 183
"now".to_string() - 184
} else if secs < 3600 { - 185
format!("{}m ago", secs / 60) - 186
} else if secs < 86_400 { - 187
format!("{}h ago", secs / 3600) - 188
} else { - 189
format!("{}d ago", secs / 86_400) - 190
} - 191
} - 192
- 193
#[derive(Debug, Clone, PartialEq, Eq)] - 194
enum MatchError { - 195
NotFound(String), - 196
Ambiguous(String), - 197
} - 198
- 199
impl std::fmt::Display for MatchError { - 200
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 201
match self { - 202
Self::NotFound(given) => write!(f, "no inbox entry matches '{given}'"), - 203
Self::Ambiguous(given) => { - 204
write!( - 205
f, - 206
"'{given}' matches several inbox entries — use more of the id" - 207
) - 208
} - 209
} - 210
} - 211
} - 212
- 213
/// Resolve a user-supplied id (full 16-hex or unique prefix) to exactly one - 214
/// entry. An exact full-id hit wins even if it is also another's prefix. - 215
fn resolve<'a>(entries: &'a [Entry], given: &str) -> Result<&'a Entry, MatchError> { - 216
if let Some(e) = entries.iter().find(|e| e.id == given) { - 217
return Ok(e); - 218
} - 219
let hits: Vec<&Entry> = entries.iter().filter(|e| e.id.starts_with(given)).collect(); - 220
match hits.as_slice() { - 221
[one] => Ok(one), - 222
[] => Err(MatchError::NotFound(given.to_string())), - 223
_ => Err(MatchError::Ambiguous(given.to_string())), - 224
} - 225
} - 226
- 227
fn load_entry(home: &Path, given: &str) -> Result<Entry, MatchError> { - 228
let ledgered = ledger(home); - 229
resolve(&ledgered, given).cloned() - 230
} - 231
- 232
/// Ack by prefix against the full ledger (acked entries stay resolvable). - 233
fn ack_prefix(home: &Path, given: &str) -> Result<bool, String> { - 234
let id = load_entry(home, given).map_err(|e| e.to_string())?.id; - 235
inbox::ack(home, &id).map_err(|e| e.to_string()) - 236
} - 237
- 238
#[cfg(test)] - 239
mod tests { - 240
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 241
use super::*; - 242
use chrono::Utc; - 243
- 244
fn entry(id: &str, kind: Kind, title: &str) -> Entry { - 245
Entry { - 246
id: id.to_string(), - 247
ts: Utc::now(), - 248
kind, - 249
title: title.to_string(), - 250
body: String::new(), - 251
session_id: None, - 252
task_id: None, - 253
result_id: None, - 254
dedupe_key: None, - 255
} - 256
} - 257
- 258
#[test] - 259
fn prefix_resolution_exact_unique_ambiguous_notfound() { - 260
let entries = vec![ - 261
entry("aaaa111111111111", Kind::Digest, "a"), - 262
entry("aaaa222222222222", Kind::Digest, "b"), - 263
entry("bbbb333333333333", Kind::Digest, "c"), - 264
]; - 265
assert_eq!( - 266
resolve(&entries, "aaaa111111111111").map(|e| e.id.as_str()), - 267
Ok("aaaa111111111111") - 268
); - 269
// Unique longer prefix disambiguates the aaaa pair. - 270
assert_eq!( - 271
resolve(&entries, "aaaa2").map(|e| e.id.as_str()), - 272
Ok("aaaa222222222222") - 273
); - 274
// Ambiguous prefixes are refused, never silently resolved. - 275
assert_eq!( - 276
resolve(&entries, "aaaa"), - 277
Err(MatchError::Ambiguous("aaaa".to_string())) - 278
); - 279
// Empty prefix matches everything, so it is ambiguous too. - 280
assert_eq!( - 281
resolve(&entries, ""), - 282
Err(MatchError::Ambiguous(String::new())) - 283
); - 284
assert_eq!( - 285
resolve(&entries, "ffff"), - 286
Err(MatchError::NotFound("ffff".to_string())) - 287
); - 288
} - 289
- 290
#[test] - 291
fn kind_chips_cover_every_kind_within_column_width() { - 292
let chips = [ - 293
(Kind::TaskSummary, "task"), - 294
(Kind::ApprovalPending, "approval"), - 295
(Kind::ApprovalDenied, "denied"), - 296
(Kind::BudgetAlert, "budget"), - 297
(Kind::Digest, "digest"), - 298
(Kind::Heartbeat, "beat"), - 299
(Kind::ProposalOpened, "proposal"), - 300
]; - 301
for (kind, chip) in chips { - 302
assert_eq!(kind_chip(kind), chip); - 303
assert!(chip.chars().count() <= 8, "{chip} breaks the column"); - 304
} - 305
} - 306
- 307
#[test] - 308
fn rows_truncate_multiline_titles_and_show_id8() { - 309
let long = entry( - 310
"0123456789abcdef", - 311
Kind::BudgetAlert, - 312
&format!("{} tail", "x".repeat(60)), - 313
); - 314
let row = format_row_rel(&long, "5m ago"); - 315
let cells: Vec<&str> = row.split(" ").filter(|c| !c.is_empty()).collect(); - 316
assert_eq!(cells[0], "5m ago"); - 317
assert_eq!(cells[1], "budget"); - 318
// Collapsed to one line, clipped under the column width, ellipsized. - 319
assert!(cells[2].ends_with('…')); - 320
assert!(cells[2].chars().count() <= TITLE_WIDTH); - 321
assert!(!cells[2].contains("tail")); - 322
assert_eq!(cells[3], "01234567"); - 323
- 324
let short = entry("fedcba9876543210", Kind::TaskSummary, "tiny\ntwo lines"); - 325
let row = format_row_rel(&short, "now"); - 326
assert!(row.contains("tiny two lines"), "{row}"); - 327
assert!(!row.contains('…'), "{row}"); - 328
assert!(row.ends_with("fedcba98")); - 329
} - 330
- 331
#[test] - 332
fn unread_all_footer_and_count_math_on_seeded_store() { - 333
let dir = tempfile::tempdir().unwrap(); - 334
let home = dir.path(); - 335
let a = inbox::record(home, Kind::Digest, "alpha one", "b", None, None).unwrap(); - 336
let b = inbox::record(home, Kind::BudgetAlert, "beta two", "b", None, None).unwrap(); - 337
let _c = inbox::record(home, Kind::Heartbeat, "gamma three", "b", None, None).unwrap(); - 338
- 339
assert_eq!(count_line(home), "3 unread"); - 340
assert_eq!(footer(home), "3 unread of 3 total"); - 341
- 342
assert!(inbox::ack(home, &b.id).unwrap()); - 343
let unread = unread_rows(home, 50); - 344
assert_eq!(unread.len(), 2, "acked entry leaves the unread view"); - 345
assert!(all_rows(home, 50).len() == 3, "--all keeps acked rows"); - 346
assert_eq!(footer(home), "2 unread of 3 total"); - 347
assert_eq!(count_line(home), "2 unread"); - 348
- 349
// --limit truncates rows but never the totals. - 350
assert_eq!(unread_rows(home, 1).len(), 1); - 351
- 352
// b was already acked above; idempotent acks report false. - 353
assert!(inbox::ack(home, &a.id).unwrap()); - 354
assert!(!inbox::ack(home, &b.id).unwrap()); - 355
assert!(inbox::ack(home, &_c.id).unwrap()); - 356
assert_eq!(footer(home), "0 unread of 3 total"); - 357
assert_eq!(count_line(home), "0 unread"); - 358
} - 359
- 360
#[test] - 361
fn ack_by_prefix_idempotent_and_unknown_is_typed_error() { - 362
let dir = tempfile::tempdir().unwrap(); - 363
let home = dir.path(); - 364
let e = inbox::record( - 365
home, - 366
Kind::ApprovalPending, - 367
"gate", - 368
"body", - 369
Some("s1"), - 370
Some("t1"), - 371
) - 372
.unwrap(); - 373
- 374
let prefix = short_hex(&e.id); - 375
assert_eq!(ack_prefix(home, &prefix), Ok(true)); - 376
assert_eq!(ack_prefix(home, &prefix), Ok(false)); - 377
- 378
let err = ack_prefix(home, "ffffffff").unwrap_err(); - 379
assert!(err.contains("no inbox entry matches 'ffffffff'"), "{err}"); - 380
- 381
// show resolves the same prefix even after the entry was acked. - 382
let shown = load_entry(home, &prefix).unwrap(); - 383
assert_eq!(shown.id, e.id); - 384
assert_eq!(shown.session_id.as_deref(), Some("s1")); - 385
assert_eq!(shown.task_id.as_deref(), Some("t1")); - 386
} - 387
- 388
#[test] - 389
fn empty_stores_render_clear_messages() { - 390
let dir = tempfile::tempdir().unwrap(); - 391
let home = dir.path(); - 392
assert_eq!(count_line(home), "0 unread"); - 393
assert!(unread_rows(home, DEFAULT_LIMIT).is_empty()); - 394
assert!(all_rows(home, DEFAULT_LIMIT).is_empty()); - 395
assert!(load_entry(home, "deadbeef").is_err()); - 396
} - 397
} - 398
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.