use super::*;
use crate::todo::{self, Item, Of, Ranked, Until};
#[derive(Default)]
pub(super) struct Todos {
pub(super) items: Vec<Item>,
pub(super) since: todo::Since,
pub(super) ranked: Option<Ranked>,
pub(super) rank_rx: Option<Receiver<Result<Ranked>>>,
pub(super) deferred: todo::Deferred,
pub(super) deferred_file: Option<PathBuf>,
pub(super) deferring: Option<String>,
pub(super) until_rx: Option<(String, Option<i64>, Receiver<Result<Until>>)>,
pub(super) auto: bool,
pub(super) auto_ranked: Option<std::time::Instant>,
}
pub(super) const GONE: &str = "not deferred: it's no longer on the list";
pub(super) enum After {
Deferred(String),
Until(String),
Not(String),
Off,
}
pub(super) type Read = (String, Until, Option<i64>);
pub(super) enum Reading {
Read(Until, Option<i64>),
Asked(String),
}
impl Todos {
pub(super) fn of(of: Of) -> Todos {
let file = todo::deferred_path_of(of);
let auto = crate::diff::git(&["config", "--get", "dv.auto"]).is_ok_and(|v| v.trim() == "true");
Todos { ranked: todo::load_of(of), deferred: todo::load_deferred(file.as_deref()), deferred_file: file, auto, ..Default::default() }
}
pub(super) fn toggle_auto(&mut self) {
self.auto = !self.auto;
_ = crate::diff::git(&["config", "dv.auto", if self.auto { "true" } else { "false" }]);
}
pub(super) fn since_text(&self) -> String {
let s = self.since;
let parts: Vec<String> = [(s.new, "new"), (s.changed, "changed"), (s.gone, "seen to")].iter().filter(|p| p.0 > 0).map(|(n, w)| format!("{n} {w}")).collect();
match (self.ranked.is_some(), parts.is_empty()) {
(false, _) => String::new(),
(true, true) => ", nothing's changed since".into(),
(true, false) => format!(", since: {}", parts.join(", ")),
}
}
pub(super) fn order_text(&self) -> String {
match (&self.rank_rx, &self.ranked) {
(Some(_), _) => "Claude is ranking…".into(),
(None, Some(r)) => format!("ranked {} ago, {}{}", crate::gh::ago_secs(crate::claude::now_secs() - r.at), cost_text(r.cost), self.since_text()),
(None, None) => "in the rules' order".into(),
}
}
pub(super) fn rank_offer(&self, what: &str) -> Result<String, &'static str> {
self.can_rank()?;
let last = match &self.ranked {
Some(r) => format!("the last, {} ago, {}", crate::gh::ago_secs(crate::claude::now_secs() - r.at), cost_text(r.cost)),
None => "never ranked before".into(),
};
Ok(format!("r again: Claude ranks the {} {what} that need you, one run ({last}{}); nothing else starts", self.to_rank(), self.since_text()))
}
pub(super) fn now(&self) -> usize {
self.items.iter().filter(|it| it.deferred.is_none() && it.busy.is_none() && it.waits.is_none()).count()
}
pub(super) fn defer_key(&mut self, it: &Item, what: &str) -> (bool, String) {
if it.deferred.is_some() {
self.forget_reading(&it.key());
self.deferred.remove(&it.key());
return (true, format!("{what} is back"));
}
self.deferring = Some(it.key());
(false, format!("defer {what} for: 1 an hour, 4 four hours, d a day, w a week, or u until something happens (any other key: not); it's back sooner if what it needs changes"))
}
pub(super) fn deferring_key(&mut self, c: KeyCode) -> Option<After> {
let key = self.deferring.take()?;
Some(match c {
KeyCode::Char(c @ ('1' | '4' | 'd' | 'w')) => {
let (secs, how) = todo::defer_secs(c);
match self.defer(&key, crate::claude::now_secs() + secs, None, 0) {
true => After::Deferred(format!("deferred for {how} (s on it brings it back)")),
false => After::Not(GONE.into()),
}
}
KeyCode::Char('u') => After::Until(key),
KeyCode::Esc => After::Off,
_ => After::Not("not deferred".into()),
})
}
pub(super) fn defer(&mut self, key: &str, until: i64, cond: Option<Until>, at: i64) -> bool {
let Some(needs) = self.items.iter().find(|it| it.key() == key).map(Item::needs_key) else { return false };
self.forget_reading(key);
self.deferred.insert(key.to_owned(), todo::Deferral { until, needs, cond, at });
true
}
fn forget_reading(&mut self, key: &str) {
if self.until_rx.as_ref().is_some_and(|r| r.0 == key) {
self.until_rx = None;
}
}
fn label(&self, key: &str) -> String {
self.items.iter().find(|it| it.key() == key).map_or_else(|| key.to_owned(), Item::label)
}
pub(super) fn defer_on(&mut self, key: &str, c: Until, or: Option<i64>, at: i64) -> String {
let (until, cond, says) = todo::until_or(c, or);
match self.defer(key, until, cond, at) {
true => format!("deferred until {says} (s on it brings it back)"),
false => GONE.into(),
}
}
pub(super) fn settle(&mut self, items: Vec<Item>, met: Vec<(String, String)>) -> (Vec<Item>, Option<String>) {
let items = todo::order(items, self.ranked.as_ref());
met.iter().for_each(|(key, _)| _ = self.deferred.remove(key));
if !met.is_empty() {
_ = self.save();
}
let items = todo::defer(items, &self.deferred, crate::claude::now_secs());
self.since = todo::since_of(&items, self.ranked.as_ref());
(items, met.into_iter().last().map(|m| m.1))
}
pub(super) fn met(&self, items: &[Item], happened: impl Fn(&Item, &Until, i64) -> bool) -> Vec<(String, String)> {
items
.iter()
.filter_map(|it| Some((it, self.deferred.get(&it.key())?)))
.filter_map(|(it, d)| Some((it, d.cond.as_ref()?, d.at)))
.filter(|(it, c, at)| happened(it, c, *at))
.map(|(it, c, _)| (it.key(), format!("{} is back: {}", it.label(), c.says())))
.collect()
}
pub(super) fn save(&self) -> Option<String> {
todo::save_deferred(self.deferred_file.as_deref(), &self.deferred).err().map(|e| format!("couldn't save it: {e:#}"))
}
pub(super) fn until(&mut self, key: String, text: &str, about: impl FnOnce() -> String, root: &std::path::Path) -> Reading {
let now = crate::claude::now_secs();
let (text, or) = todo::or_time(text, now);
match Until::parse(&text, now) {
Some(c) => Reading::Read(c, or),
None => {
let instead = self.until_rx.as_ref().map_or(String::new(), |r| format!(" (instead of the \"until\" for {})", self.label(&r.0)));
self.until_rx = Some((key, or, todo::interpret(text, about(), root.to_path_buf())));
Reading::Asked(format!("Claude is reading when you mean…{instead}"))
}
}
}
pub(super) fn until_news(&mut self) -> Option<std::result::Result<Read, String>> {
let res = self.until_rx.as_ref()?.2.try_recv().ok()?;
let (key, or, _) = self.until_rx.take()?;
Some(res.map(|c| (key, c, or)).map_err(|e| format!("not deferred: {e:#}")))
}
pub(super) fn to_rank(&self) -> usize {
self.items.iter().filter(|it| it.deferred.is_none()).count()
}
pub(super) fn can_rank(&self) -> Result<(), &'static str> {
match (self.rank_rx.is_some(), self.to_rank(), self.items.is_empty()) {
(true, ..) => Err("Claude is ranking already"),
(_, 0, true) => Err("nothing on the todo tab to rank"),
(_, 0, false) => Err("nothing on the todo tab to rank (what's there is deferred)"),
_ => Ok(()),
}
}
pub(super) fn rank(&mut self, items: Vec<(todo::Entry, String)>, root: &std::path::Path, of: Of) -> Result<(), &'static str> {
self.can_rank()?;
if items.is_empty() {
return Err("nothing on the todo tab to rank");
}
let (entries, lines): (Vec<todo::Entry>, Vec<String>) = items.into_iter().unzip();
let fingerprint = todo::fingerprint_of(entries);
self.rank_rx = Some(todo::rank_of(lines, fingerprint, root.to_path_buf(), of));
Ok(())
}
pub(super) fn rank_news(&mut self) -> Option<Result<f64>> {
let res = self.rank_rx.as_ref()?.try_recv().ok()?;
self.rank_rx = None;
Some(res.map(|r| (r.cost, self.ranked = Some(r)).0))
}
pub(super) fn hold_auto_rank(&mut self) {
self.auto_ranked = Some(std::time::Instant::now());
}
pub(super) fn auto_rank_due(&mut self) -> bool {
let due = self.auto_ranked.is_none_or(|t| t.elapsed() > std::time::Duration::from_secs(15 * 60));
let changed = self.ranked.is_none() || self.since.new + self.since.changed > 0;
let due = due && changed && self.can_rank().is_ok();
if due {
self.auto_ranked = Some(std::time::Instant::now());
}
due
}
}
pub(super) fn cost_text(c: f64) -> String {
if c > 0.0 { format!("cost ${c:.2}") } else { "cost not recorded".into() }
}
#[test]
fn deferring_an_item() {
use todo::{Kind, Need};
let it = Item { kind: Kind::Issue, number: 7, needs: vec![Need::Untriaged], claude: None, updated: String::new(), reason: None, changed: false, deferred: None, busy: None, waits: None };
let file = std::env::temp_dir().join(format!("dv-todos-{}.json", std::process::id()));
let mut t = Todos { items: vec![it.clone()], deferred_file: Some(file.clone()), ..Default::default() };
let (changed, said) = t.defer_key(&it, "#7");
assert!(!changed && said.starts_with("defer #7 for:"));
assert!(matches!(t.deferring_key(KeyCode::Char('d')), Some(After::Deferred(s)) if s.starts_with("deferred for a day")));
assert!(t.deferred.contains_key("issue-7") && t.save().is_none() && std::fs::read_to_string(&file).unwrap().contains("issue-7"));
assert!(t.deferring_key(KeyCode::Char('d')).is_none());
t.deferring = Some("issue-7".into());
assert!(matches!(t.deferring_key(KeyCode::Char('x')), Some(After::Not(_))));
t.deferring = Some("issue-7".into());
assert!(matches!(t.deferring_key(KeyCode::Esc), Some(After::Off)));
t.deferring = Some("issue-9".into());
assert!(matches!(t.deferring_key(KeyCode::Char('d')), Some(After::Not(s)) if s == GONE));
t.deferring = Some("issue-7".into());
assert!(matches!(t.deferring_key(KeyCode::Char('u')), Some(After::Until(k)) if k == "issue-7"));
t.deferred.insert("issue-7".into(), todo::Deferral { until: i64::MAX, needs: it.needs_key(), cond: Some(Until::Merged(2380)), at: 0 });
assert!(t.met(&t.items, |_, _, _| false).is_empty());
assert_eq!(t.met(&t.items, |_, c, _| *c == Until::Merged(2380)), [("issue-7".to_owned(), "#7 is back: #2380 is merged".to_owned())]);
t.deferred.remove("issue-7");
let (_tx, rx) = std::sync::mpsc::channel();
t.until_rx = Some(("issue-7".into(), None, rx));
assert!(t.defer("issue-7", 1, None, 0) && t.until_rx.is_none());
assert!(matches!(t.until("issue-7".into(), "3 days", String::new, &file), Reading::Read(Until::Time(_), None)));
let deferred = Item { deferred: t.deferred.get("issue-7").cloned(), ..it };
assert!(t.defer_key(&deferred, "#7") == (true, "#7 is back".into()) && t.deferred.is_empty());
let all_deferred = Todos { items: vec![deferred.clone()], ..Default::default() };
assert!(all_deferred.rank_offer("issue(s)").is_err() && all_deferred.can_rank().is_err());
assert_eq!(Todos::default().can_rank(), Err("nothing on the todo tab to rank")); assert!(t.auto_rank_due() && !t.auto_rank_due());
let eight = Item { number: 8, ..t.items[0].clone() };
t.items.push(Item { deferred: Some(todo::Deferral { until: i64::MAX, needs: String::new(), cond: None, at: 0 }), ..eight.clone() });
let ranked = Ranked { at: 1, cost: 0.0, fingerprint: todo::fingerprint_of(t.items.iter().filter(|it| it.deferred.is_none()).map(Item::entry)), items: vec![], waits: Default::default(), closes: Default::default() };
let since = |items: &[Item]| todo::since_of(items, Some(&ranked));
assert_eq!((since(&t.items).new, since(&t.items).changed), (0, 0));
assert_eq!(since(&[t.items[0].clone(), eight]).new, 1);
_ = std::fs::remove_file(file);
}