synops/tools/synops-tasks/src/main.rs
vegard da2155e34e Implementer synops-tasks CLI-verktøy (oppgave 21.12)
Parser tasks.md og viser formatert oppgavestatus med filtrering
på fase (--phase N) og status (--status todo|done|blocked|inprogress|question).
Viser sammendrag med antall per status og fremdriftsbar.

Rent filbasert verktøy — ingen database nødvendig. Finner tasks.md
automatisk fra arbeidsdir eller absolutt sti.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:11:46 +00:00

267 lines
7.3 KiB
Rust

// synops-tasks — Parse tasks.md og vis oppgavestatus.
//
// Leser tasks.md fra repo-roten og parser oppgaver med status,
// fase og detaljer. Støtter filtrering på fase og status.
//
// Output: formatert oppgaveliste til stdout.
//
// Ref: docs/retninger/unix_filosofi.md
use clap::Parser;
use regex::Regex;
use std::path::PathBuf;
use std::process;
/// Parse tasks.md og vis oppgavestatus.
#[derive(Parser)]
#[command(name = "synops-tasks", about = "Vis oppgavestatus fra tasks.md")]
struct Cli {
/// Filtrer på fase (f.eks. 21 for "Fase 21")
#[arg(long)]
phase: Option<u32>,
/// Filtrer på status: todo, done, blocked, inprogress, question, all
#[arg(long, default_value = "all")]
status: String,
}
#[derive(Debug, Clone)]
struct Task {
phase: u32,
phase_title: String,
id: String,
title: String,
status: TaskStatus,
details: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
enum TaskStatus {
Todo,
InProgress,
Done,
Question,
Blocked,
}
impl TaskStatus {
fn symbol(&self) -> &str {
match self {
TaskStatus::Todo => "[ ]",
TaskStatus::InProgress => "[~]",
TaskStatus::Done => "[x]",
TaskStatus::Question => "[?]",
TaskStatus::Blocked => "[!]",
}
}
fn label(&self) -> &str {
match self {
TaskStatus::Todo => "Klar",
TaskStatus::InProgress => "Pågår",
TaskStatus::Done => "Ferdig",
TaskStatus::Question => "Spørsmål",
TaskStatus::Blocked => "Blokkert",
}
}
fn matches_filter(&self, filter: &str) -> bool {
match filter {
"all" => true,
"todo" => *self == TaskStatus::Todo,
"done" => *self == TaskStatus::Done,
"blocked" => *self == TaskStatus::Blocked || *self == TaskStatus::Question,
"inprogress" => *self == TaskStatus::InProgress,
"question" => *self == TaskStatus::Question,
_ => true,
}
}
}
fn find_tasks_md() -> Result<PathBuf, String> {
// Prøv relativ sti først (repo-rot), deretter absolutt
let candidates = [
PathBuf::from("tasks.md"),
PathBuf::from("/home/vegard/synops/tasks.md"),
];
for path in &candidates {
if path.exists() {
return Ok(path.clone());
}
}
Err("Fant ikke tasks.md (prøvde ./ og /home/vegard/synops/)".to_string())
}
fn parse_tasks(content: &str) -> Vec<Task> {
let phase_re = Regex::new(r"^## Fase (\d+):\s*(.+)$").unwrap();
let task_re = Regex::new(r"^- \[(.)\] (\d+\.\d+)\s+(.+)$").unwrap();
let detail_re = Regex::new(r"^ > (.+)$").unwrap();
let mut tasks = Vec::new();
let mut current_phase: Option<(u32, String)> = None;
for line in content.lines() {
if let Some(caps) = phase_re.captures(line) {
let num: u32 = caps[1].parse().unwrap_or(0);
let title = caps[2].trim().to_string();
current_phase = Some((num, title));
continue;
}
if let Some(caps) = task_re.captures(line) {
let status_char = &caps[1];
let id = caps[2].to_string();
let title = caps[3].to_string();
let status = match status_char {
" " => TaskStatus::Todo,
"~" => TaskStatus::InProgress,
"x" => TaskStatus::Done,
"?" => TaskStatus::Question,
"!" => TaskStatus::Blocked,
_ => TaskStatus::Todo,
};
let (phase, phase_title) = current_phase
.clone()
.unwrap_or((0, "Ukjent".to_string()));
tasks.push(Task {
phase,
phase_title,
id,
title,
status,
details: Vec::new(),
});
continue;
}
if let Some(caps) = detail_re.captures(line) {
if let Some(task) = tasks.last_mut() {
task.details.push(caps[1].to_string());
}
}
}
tasks
}
fn main() {
let cli = Cli::parse();
let valid_statuses = ["all", "todo", "done", "blocked", "inprogress", "question"];
if !valid_statuses.contains(&cli.status.as_str()) {
eprintln!(
"Ugyldig status «{}». Gyldige verdier: {}",
cli.status,
valid_statuses.join(", ")
);
process::exit(1);
}
let path = match find_tasks_md() {
Ok(p) => p,
Err(e) => {
eprintln!("Feil: {e}");
process::exit(1);
}
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("Kunne ikke lese {}: {e}", path.display());
process::exit(1);
}
};
let tasks = parse_tasks(&content);
// Filtrer
let filtered: Vec<&Task> = tasks
.iter()
.filter(|t| {
if let Some(phase) = cli.phase {
if t.phase != phase {
return false;
}
}
t.status.matches_filter(&cli.status)
})
.collect();
if filtered.is_empty() {
let mut msg = String::from("Ingen oppgaver funnet");
if let Some(phase) = cli.phase {
msg.push_str(&format!(" i fase {phase}"));
}
if cli.status != "all" {
msg.push_str(&format!(" med status «{}»", cli.status));
}
println!("{msg}.");
return;
}
// Grupper etter fase
let mut current_phase: Option<u32> = None;
// Sammendrag
let total = filtered.len();
let done = filtered.iter().filter(|t| t.status == TaskStatus::Done).count();
let todo = filtered.iter().filter(|t| t.status == TaskStatus::Todo).count();
let progress = filtered
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
.count();
let blocked = filtered
.iter()
.filter(|t| t.status == TaskStatus::Blocked || t.status == TaskStatus::Question)
.count();
if let Some(phase) = cli.phase {
println!("# Fase {phase}\n");
} else {
println!("# Oppgavestatus\n");
}
println!(
"Totalt: {total} | Ferdig: {done} | Klar: {todo} | Pågår: {progress} | Blokkert: {blocked}\n"
);
for task in &filtered {
if current_phase != Some(task.phase) {
current_phase = Some(task.phase);
println!("## Fase {}: {}\n", task.phase, task.phase_title);
}
println!(
" {} {} {}",
task.status.symbol(),
task.id,
task.title
);
// Vis statuslabel for ikke-ferdige oppgaver
if task.status != TaskStatus::Done {
println!(" Status: {}", task.status.label());
}
for detail in &task.details {
println!(" > {detail}");
}
}
// Fremdriftsbar for filtrert sett
if total > 0 {
let pct = (done as f64 / total as f64 * 100.0) as u32;
let bar_width = 30;
let filled = (pct as usize * bar_width) / 100;
let empty = bar_width - filled;
println!(
"\nFremdrift: [{}{}] {pct}% ({done}/{total})",
"".repeat(filled),
"".repeat(empty)
);
}
}