use std::path::Path;
use anyhow::{Context, Result, anyhow};
use chrono::{Datelike, Duration, Local, NaiveDate};
use clap::{Args, ValueEnum};
use org_core::{
AgendaEngine, AgendaEntry, AgendaQuery, AgendaSpan, JournalStatus, Settings, ViewSection,
WorkspaceIndex, WriteEngine, agenda_csv, agenda_title, items_csv, load_settings,
parse_date_input,
};
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum SpanArg {
Day,
Week,
Month,
}
#[derive(Args, Debug)]
pub struct AgendaCommand {
#[arg(long, value_enum)]
span: Option<SpanArg>,
#[arg(long)]
from: Option<String>,
#[arg(long = "match")]
match_expression: Option<String>,
#[arg(long, conflicts_with_all = ["span", "match_expression"])]
view: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
log: bool,
#[arg(long)]
brief: bool,
#[arg(long, conflicts_with_all = ["json", "csv", "brief", "view"])]
status: bool,
#[arg(long, conflicts_with_all = ["csv", "brief"])]
json: bool,
#[arg(long, conflicts_with = "brief")]
csv: bool,
}
impl AgendaCommand {
pub fn execute(self, config: Option<String>, root: Option<String>) -> Result<()> {
let mut settings = load_settings(
config.as_deref().map(Path::new),
root.as_deref().map(Path::new),
)?
.settings;
let mut match_expression = self.match_expression;
if let Some(name) = self.profile.as_deref() {
let profile = settings
.agent_profile(name)
.cloned()
.ok_or_else(|| anyhow!("unknown agent agenda profile: {name}"))?;
if !profile.files.is_empty() {
settings.agenda_files = profile.files;
}
match_expression = match_expression.or(profile.match_expression);
}
let index = load_index(settings.clone())?;
let today = Local::now().date_naive();
if self.status {
println!("{}", status_line(&index, &settings, today)?);
return Ok(());
}
let start = match self.from {
Some(value) => parse_day(&value, today)?,
None => default_start(&settings, today)?,
};
if let Some(key) = self.view.as_deref() {
let view = settings.view(key).ok_or_else(|| {
let keys: Vec<&str> = settings
.views
.iter()
.map(|view| view.key.as_str())
.collect();
anyhow!(
"unknown view '{key}'; configured views: {}",
keys.join(", ")
)
})?;
let sections = AgendaEngine::new(&index).view(view, start, today, self.log)?;
if self.json {
println!("{}", serde_json::to_string_pretty(§ions)?);
} else if self.csv {
for section in §ions {
print!(
"{}{}",
agenda_csv(§ion.entries),
items_csv(§ion.items)
);
}
} else {
print!("{}", render_view(§ions, &settings));
}
return Ok(());
}
let span = match self.span {
Some(SpanArg::Day) => AgendaSpan::Day,
Some(SpanArg::Week) => AgendaSpan::Week,
Some(SpanArg::Month) => AgendaSpan::Month,
None => span_from_settings(&settings),
};
let (from, to) = span.bounds(start, &settings.week_start);
let entries = AgendaEngine::new(&index).agenda(&AgendaQuery {
from,
to,
today,
match_expression,
log_mode: self.log,
})?;
if self.json {
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if self.csv {
print!("{}", agenda_csv(&entries));
} else if self.brief {
for entry in &entries {
println!("{} {}", entry.occurrence.date, agenda_title(&entry.item));
}
} else {
print!("{}", render_agenda(&entries, from, to, span));
}
Ok(())
}
}
fn render_agenda(
entries: &[AgendaEntry],
from: NaiveDate,
to: NaiveDate,
span: AgendaSpan,
) -> String {
let mut output = String::new();
if span == AgendaSpan::Week {
output.push_str(&format!("Week-agenda (W{:02}):\n", from.iso_week().week()));
}
let mut day = from;
while day <= to {
let date = day.format("%Y-%m-%d").to_string();
let rows: Vec<&AgendaEntry> = entries
.iter()
.filter(|entry| entry.occurrence.date == date)
.collect();
if span != AgendaSpan::Month || !rows.is_empty() {
output.push_str(&format!(
"{:<10} {:>2} {} {}{}\n",
day.format("%A"),
day.day(),
day.format("%B"),
day.year(),
if span == AgendaSpan::Week && day.weekday().num_days_from_monday() == 0 {
format!(" W{:02}", day.iso_week().week())
} else {
String::new()
}
));
}
for entry in rows {
output.push_str(&agenda_line(entry));
output.push('\n');
}
day += Duration::days(1);
}
output
}
fn agenda_line(entry: &AgendaEntry) -> String {
let item = &entry.item;
let mut line = format!(" {:<12}", format!("{}:", item.category));
if let Some(time) = &entry.occurrence.time {
line.push_str(&format!("{time}...... "));
}
if !entry.occurrence.extra.is_empty() {
line.push_str(&format!("{:<11} ", entry.occurrence.extra));
}
if let Some(todo) = &item.todo {
line.push_str(todo);
line.push(' ');
}
if let Some(priority) = &item.priority {
line.push_str(&format!("[#{priority}] "));
}
line.push_str(&agenda_title(item));
if !item.tags.is_empty() {
line.push_str(&format!(" :{}:", item.tags.join(":")));
}
line
}
fn render_view(sections: &[ViewSection], settings: &Settings) -> String {
let mut blocks = Vec::new();
for section in sections {
let mut block = String::new();
if let (Some(from), Some(to)) = (section.from, section.to) {
if !section.header.is_empty() {
block.push_str(&format!("{}\n", section.header));
}
let span = span_for_bounds(from, to, settings);
block.push_str(&render_agenda(§ion.entries, from, to, span));
} else {
block.push_str(&format!("{}\n", section.header));
for item in §ion.items {
let mut line = format!(" {:<12}", format!("{}:", item.category));
if let Some(todo) = &item.todo {
line.push_str(&format!("{todo} "));
}
if let Some(priority) = &item.priority {
line.push_str(&format!("[#{priority}] "));
}
line.push_str(&agenda_title(item));
if !item.tags.is_empty() {
line.push_str(&format!(" :{}:", item.tags.join(":")));
}
block.push_str(&line);
block.push('\n');
}
}
blocks.push(block);
}
blocks.join(&format!("{}\n", "=".repeat(78)))
}
fn span_for_bounds(from: NaiveDate, to: NaiveDate, settings: &Settings) -> AgendaSpan {
if from == to {
AgendaSpan::Day
} else if AgendaSpan::Week.bounds(from, &settings.week_start) == (from, to) {
AgendaSpan::Week
} else {
AgendaSpan::Month
}
}
fn status_line(index: &WorkspaceIndex, settings: &Settings, today: NaiveDate) -> Result<String> {
let engine = AgendaEngine::new(index);
let entries = engine.agenda(&AgendaQuery {
from: today,
to: today,
today,
match_expression: None,
log_mode: false,
})?;
let slipped_kind = |entry: &AgendaEntry| {
entry.occurrence.kind == org_core::OccurrenceType::PastScheduled
|| entry.occurrence.extra.ends_with("ago:")
};
let mut slipped = std::collections::BTreeSet::new();
let mut open = std::collections::BTreeSet::new();
for entry in entries.iter().filter(|entry| !entry.item.done) {
if slipped_kind(entry) {
slipped.insert(&entry.item.item_ref);
} else {
open.insert(&entry.item.item_ref);
}
}
let agents = WriteEngine::with_default_journal(settings.clone())?
.journal()?
.iter()
.filter(|record| {
record.actor.starts_with("mcp:") && record.status == JournalStatus::Committed
})
.count();
let counts = [
(slipped.len(), "slipped"),
(open.len(), "today"),
(engine.inbox().len(), "inbox"),
(agents, "agents"),
];
Ok(counts
.iter()
.filter(|(count, _)| *count > 0)
.map(|(count, label)| format!("{count} {label}"))
.collect::<Vec<_>>()
.join(" · "))
}
pub fn load_index(settings: Settings) -> Result<WorkspaceIndex> {
let index = WorkspaceIndex::load(settings)?;
for (_, reason) in index.skipped_files() {
eprintln!("warning: skipped {reason}");
}
Ok(index)
}
fn parse_day(value: &str, today: NaiveDate) -> Result<NaiveDate> {
if let Some(days) = value.strip_prefix('-') {
let back = parse_date_input(&format!("+{days}"), today)?
.date
.parse::<NaiveDate>()?;
return Ok(today - (back - today));
}
Ok(parse_date_input(value, today)?.date.parse::<NaiveDate>()?)
}
fn default_start(settings: &Settings, today: NaiveDate) -> Result<NaiveDate> {
let Some(value) = settings.agenda_start_day.as_deref() else {
return Ok(today);
};
parse_day(value, today).with_context(|| format!("invalid agenda start_day: {value}"))
}
fn span_from_settings(settings: &Settings) -> AgendaSpan {
match settings.agenda_span.as_str() {
"day" => AgendaSpan::Day,
"month" => AgendaSpan::Month,
_ => AgendaSpan::Week,
}
}