mod app;
mod input;
mod style;
pub(crate) mod watcher;
mod widgets;
use std::collections::HashMap;
use std::path::Path;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::DefaultTerminal;
use bears::error::Result;
use bears::store;
use bears::task::Task;
pub use app::{Action, App};
type WatcherHandle = Box<dyn std::any::Any + Send>;
fn start_watcher(bears_dir: &Path) -> (WatcherHandle, tokio::sync::mpsc::Receiver<()>) {
match watcher::watch_bears_dir(bears_dir) {
Ok((debouncer, rx)) => (Box::new(debouncer), rx),
Err(e) => {
eprintln!("warn: file watcher could not start ({e}); live reload disabled");
let (tx, rx) = tokio::sync::mpsc::channel(1);
drop(tx); (Box::new(()), rx)
}
}
}
fn load_tasks_sync(base: &Path) -> Result<(Vec<Task>, HashMap<String, Task>)> {
let task_map = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(store::load_all(base))
})?;
let mut task_list: Vec<Task> = task_map.values().cloned().collect();
task_list.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.created.cmp(&b.created)));
Ok((task_list, task_map))
}
fn load_archived_sync(base: &Path) -> Result<Vec<Task>> {
let archived = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(store::load_archived(base))
})?;
let mut list: Vec<Task> = archived.into_values().collect();
list.sort_by(|a, b| b.updated.cmp(&a.updated).then(a.id.cmp(&b.id)));
Ok(list)
}
pub async fn run(base: &Path) -> Result<()> {
let (task_list, task_map) = load_tasks_sync(base)?;
let mut app = App::new(task_list, task_map, base.to_path_buf());
app.archived_tasks = load_archived_sync(base)?;
let mut terminal = ratatui::init();
let bears_dir = base.join(".bears");
let (_watcher, mut watcher_rx) = start_watcher(&bears_dir);
let result = run_loop(&mut app, &mut terminal, &mut watcher_rx).await;
ratatui::restore();
result
}
pub(crate) fn reload(app: &mut App) -> Result<()> {
let (task_list, task_map) = load_tasks_sync(&app.base)?;
let archived = load_archived_sync(&app.base)?;
app.reload_with_archived(task_list, task_map, archived);
Ok(())
}
fn edit_task_in_editor(app: &App, task_id: &str, terminal: &mut DefaultTerminal) -> Result<()> {
let path = store::find_task_path(&app.base, task_id)?;
ratatui::restore();
let result = crate::editor::open_in_editor(&path);
*terminal = ratatui::init();
result
}
fn create_task(app: &App, title: &str, terminal: &mut DefaultTerminal) -> Result<String> {
use bears::service;
use bears::task::{Priority, TaskType};
let (_, task_map) = load_tasks_sync(&app.base)?;
let task = service::create_task(
&app.base,
&task_map,
title.to_string(),
Priority::P2,
Vec::new(),
Vec::new(),
None,
String::new(),
TaskType::Task,
)?;
let id = task.id.clone();
edit_task_in_editor(app, &task.id, terminal)?;
Ok(id)
}
async fn read_crossterm_event() -> Option<Event> {
tokio::task::spawn_blocking(|| crossterm::event::read().ok())
.await
.ok()
.flatten()
}
async fn run_loop(
app: &mut App,
terminal: &mut DefaultTerminal,
watcher_rx: &mut tokio::sync::mpsc::Receiver<()>,
) -> Result<()> {
let mut read_fut = Box::pin(read_crossterm_event());
let mut watcher_live = true;
loop {
terminal.draw(|frame| app.render(frame))?;
tokio::select! {
event = &mut read_fut => {
read_fut = Box::pin(read_crossterm_event());
let Some(Event::Key(key)) = event else { continue };
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
break;
}
let action = app.handle_key(key);
match action {
Action::None => {}
Action::Quit => break,
Action::EditSelected => {
if let Some(task) = app.selected_task() {
let id = task.id.clone();
match edit_task_in_editor(app, &id, terminal).and_then(|_| reload(app)) {
Ok(()) => app.error_message = None,
Err(e) => app.error_message = Some(e.to_string()),
}
}
}
Action::CreateTask(title) => {
match create_task(app, &title, terminal).and_then(|new_id| {
reload(app)?;
if let Some(idx) = app.tasks.iter().position(|t| t.id == new_id) {
app.list_state.select(Some(idx));
}
Ok(())
}) {
Ok(()) => app.error_message = None,
Err(e) => app.error_message = Some(e.to_string()),
}
}
Action::UpdateStatus(id, status) => {
match (|| -> Result<()> {
let (_, task_map) = load_tasks_sync(&app.base)?;
bears::service::set_status(&app.base, &task_map, &id, status)?;
reload(app)?;
Ok(())
})() {
Ok(()) => app.error_message = None,
Err(e) => app.error_message = Some(e.to_string()),
}
}
Action::DeleteTask(id) => {
match (|| -> Result<()> {
let (_, task_map) = load_tasks_sync(&app.base)?;
bears::service::delete_task(&app.base, &task_map, &id)?;
reload(app)?;
Ok(())
})() {
Ok(()) => app.error_message = None,
Err(e) => app.error_message = Some(e.to_string()),
}
}
}
}
signal = watcher_rx.recv(), if watcher_live => {
match signal {
Some(()) => match reload(app) {
Ok(()) => app.error_message = None,
Err(e) => app.error_message = Some(e.to_string()),
},
None => watcher_live = false,
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use bears::task::{Priority, Status, Task};
use std::collections::HashMap;
use std::path::PathBuf;
fn make_app_with_base(base: PathBuf) -> App {
App::new(vec![], HashMap::new(), base)
}
#[test]
fn reload_nonexistent_base_returns_error() {
let mut app = make_app_with_base(PathBuf::from("/tmp/__bears_nonexistent__"));
let result = tokio::runtime::Runtime::new()
.unwrap()
.block_on(async { reload(&mut app) });
assert!(result.is_err(), "reload on missing base should fail");
}
#[test]
fn set_status_unknown_task_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let bears_dir = tmp.path().join(".bears");
std::fs::create_dir_all(&bears_dir).unwrap();
let tasks: HashMap<String, Task> = HashMap::new();
let result = bears::service::set_status(
tmp.path(),
&tasks,
"nonexistent",
bears::task::Status::Done,
);
assert!(result.is_err(), "set_status on unknown task ID should fail");
}
#[test]
fn error_message_set_on_reload_failure() {
let mut app = make_app_with_base(PathBuf::from("/tmp/__bears_no_dir__"));
let result = tokio::runtime::Runtime::new()
.unwrap()
.block_on(async { reload(&mut app) });
let err_msg = result.unwrap_err().to_string();
app.error_message = Some(err_msg.clone());
assert!(app.error_message.is_some());
assert!(!err_msg.is_empty());
}
#[test]
fn error_message_cleared_on_success() {
let mut app = make_app_with_base(PathBuf::from("/tmp/__bears_test__"));
app.error_message = Some("previous error".into());
app.error_message = None;
assert!(app.error_message.is_none());
}
fn make_open_tasks(ids_titles: &[(&str, &str)]) -> (Vec<Task>, HashMap<String, Task>) {
let tasks: Vec<Task> = ids_titles
.iter()
.map(|(id, title)| {
let mut t = Task::new(id.to_string(), title.to_string(), Priority::P1);
t.status = Status::Open;
t
})
.collect();
let map = tasks.iter().map(|t| (t.id.clone(), t.clone())).collect();
(tasks, map)
}
#[test]
fn reload_preserves_selection_by_id() {
let (tasks, map) =
make_open_tasks(&[("aaa", "Task A"), ("bbb", "Task B"), ("ccc", "Task C")]);
let mut app = App::new(tasks, map, PathBuf::from("."));
app.list_state.select(Some(1));
assert_eq!(app.selected_task().map(|t| t.id.as_str()), Some("bbb"));
let (new_tasks, new_map) = make_open_tasks(&[
("aaa", "Task A"),
("aaa2", "Task A2"),
("bbb", "Task B"),
("ccc", "Task C"),
]);
app.reload(new_tasks, new_map);
assert_eq!(
app.selected_task().map(|t| t.id.as_str()),
Some("bbb"),
"selection should follow task 'bbb' after reload inserts a new task"
);
assert_eq!(
app.selected_index(),
Some(2),
"index should be 2 after aaa2 was inserted before bbb"
);
}
#[test]
fn reload_falls_back_to_neighbour_when_task_deleted() {
let (tasks, map) = make_open_tasks(&[
("aaa", "Task A"),
("bbb", "Task B"),
("ccc", "Task C"),
("ddd", "Task D"),
]);
let mut app = App::new(tasks, map, PathBuf::from("."));
app.list_state.select(Some(2));
assert_eq!(app.selected_task().map(|t| t.id.as_str()), Some("ccc"));
let (new_tasks, new_map) =
make_open_tasks(&[("aaa", "Task A"), ("bbb", "Task B"), ("ddd", "Task D")]);
app.reload(new_tasks, new_map);
assert_eq!(
app.selected_task().map(|t| t.id.as_str()),
Some("ddd"),
"should fall back to the task now at the old index (clamped)"
);
assert_eq!(app.selected_index(), Some(2));
}
#[test]
fn reload_clamps_index_when_list_shrinks() {
let (tasks, map) =
make_open_tasks(&[("aaa", "Task A"), ("bbb", "Task B"), ("ccc", "Task C")]);
let mut app = App::new(tasks, map, PathBuf::from("."));
app.list_state.select(Some(2));
let (new_tasks, new_map) = make_open_tasks(&[("aaa", "Task A")]);
app.reload(new_tasks, new_map);
assert_eq!(app.selected_index(), Some(0));
assert_eq!(app.selected_task().map(|t| t.id.as_str()), Some("aaa"));
}
#[test]
fn reload_preserves_mode_and_search_query() {
use crate::tui::app::{Filter, ListMode};
let (tasks, map) = make_open_tasks(&[("aaa", "Task A alpha"), ("bbb", "Task B beta")]);
let mut app = App::new(tasks, map, PathBuf::from("."));
app.filter = Filter {
list_mode: ListMode::All,
query: "alpha".to_string(),
};
app.apply_filter();
assert_eq!(app.tasks.len(), 1);
assert_eq!(app.tasks[0].id, "aaa");
let (new_tasks, new_map) =
make_open_tasks(&[("aaa", "Task A alpha"), ("bbb", "Task B beta")]);
app.reload(new_tasks, new_map);
assert_eq!(
app.filter.list_mode,
ListMode::All,
"list mode must survive reload"
);
assert_eq!(
app.filter.query, "alpha",
"search query must survive reload"
);
assert_eq!(
app.tasks.len(),
1,
"filter must still be applied after reload"
);
assert_eq!(app.tasks[0].id, "aaa");
}
#[test]
fn reload_clamps_detail_scroll() {
let (tasks, map) = make_open_tasks(&[("aaa", "Task A")]);
let mut app = App::new(tasks, map, PathBuf::from("."));
app.detail_scroll = 50;
app.detail_content_height = 10;
app.detail_visible_height = 10;
let (new_tasks, new_map) = make_open_tasks(&[("aaa", "Task A")]);
app.reload(new_tasks, new_map);
assert_eq!(
app.detail_scroll, 0,
"scroll must be clamped to new content height after reload"
);
}
#[tokio::test]
async fn watcher_channel_closed_yields_none() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
drop(tx);
assert!(
rx.recv().await.is_none(),
"closed channel must yield None so run_loop can disable the watcher branch"
);
}
fn write_task_file(bears_dir: &std::path::Path, id: &str, title: &str, status: &str) {
let content = format!(
"---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: P2\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n"
);
let filename = format!("{id}-{}.md", title.to_lowercase().replace(' ', "-"));
std::fs::write(bears_dir.join(filename), content).expect("write task file");
}
#[tokio::test]
async fn reload_applies_disk_state_to_app() {
let tmp = tempfile::tempdir().unwrap();
bears::store::init(tmp.path()).unwrap();
let bears_dir = tmp.path().join(".bears");
write_task_file(&bears_dir, "t01", "Alpha Task", "open");
write_task_file(&bears_dir, "t02", "Beta Task", "open");
write_task_file(&bears_dir, "t03", "Gamma Task", "done");
let task_map = bears::store::load_all(tmp.path()).await.unwrap();
let mut task_list: Vec<Task> = task_map.values().cloned().collect();
task_list.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.created.cmp(&b.created)));
let mut app = App::new(task_list, task_map, tmp.path().to_path_buf());
assert_eq!(
app.all_tasks.len(),
3,
"all_tasks should hold every task from disk"
);
assert!(app.task_map.contains_key("t01"));
assert!(app.task_map.contains_key("t02"));
assert!(app.task_map.contains_key("t03"));
let open_visible: Vec<&str> = app.tasks.iter().map(|t| t.id.as_str()).collect();
assert!(
open_visible.contains(&"t01") && open_visible.contains(&"t02"),
"open tasks must be visible"
);
assert!(
!open_visible.contains(&"t03"),
"done task must be hidden by default Open filter"
);
write_task_file(&bears_dir, "t04", "Delta Task", "open");
std::fs::remove_file(bears_dir.join("t02-beta-task.md")).unwrap();
let new_map = bears::store::load_all(tmp.path()).await.unwrap();
let mut new_list: Vec<Task> = new_map.values().cloned().collect();
new_list.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.created.cmp(&b.created)));
app.reload(new_list, new_map);
assert_eq!(app.all_tasks.len(), 3, "t02 deleted, t04 added → 3 tasks");
assert!(
app.task_map.contains_key("t01"),
"t01 must still be present"
);
assert!(
!app.task_map.contains_key("t02"),
"t02 must be gone after deletion"
);
assert!(
app.task_map.contains_key("t04"),
"t04 must appear after add"
);
let visible_ids: Vec<&str> = app.tasks.iter().map(|t| t.id.as_str()).collect();
assert!(visible_ids.contains(&"t01"), "t01 should be visible");
assert!(visible_ids.contains(&"t04"), "t04 should be visible");
assert!(
!visible_ids.contains(&"t02"),
"deleted t02 must not be visible"
);
assert!(
!visible_ids.contains(&"t03"),
"done t03 still hidden by Open filter"
);
assert!(
bears::graph::is_task_ready(&app.task_map, app.task_map.get("t01").unwrap()),
"t01 has no deps, so it must be ready"
);
assert!(
bears::graph::is_task_ready(&app.task_map, app.task_map.get("t04").unwrap()),
"t04 has no deps, so it must be ready"
);
}
#[tokio::test]
async fn reload_rebuilds_graph_correctly() {
let tmp = tempfile::tempdir().unwrap();
bears::store::init(tmp.path()).unwrap();
let bears_dir = tmp.path().join(".bears");
write_task_file(&bears_dir, "u01", "Upstream Task", "open");
write_task_file(&bears_dir, "u02", "Downstream Task", "open");
let task_map = bears::store::load_all(tmp.path()).await.unwrap();
let task_list: Vec<Task> = task_map.values().cloned().collect();
let mut app = App::new(task_list, task_map, tmp.path().to_path_buf());
assert!(bears::graph::is_task_ready(
&app.task_map,
app.task_map.get("u02").unwrap()
));
let blocked_content = "---\nid: u02\ntitle: Downstream Task\nstatus: open\npriority: P2\n\
depends_on: [u01]\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:01Z\n---\n";
std::fs::write(bears_dir.join("u02-downstream-task.md"), blocked_content).unwrap();
let new_map = bears::store::load_all(tmp.path()).await.unwrap();
let new_list: Vec<Task> = new_map.values().cloned().collect();
app.reload(new_list, new_map);
assert!(
!bears::graph::is_task_ready(&app.task_map, app.task_map.get("u02").unwrap()),
"u02 must not be ready after reload adds dep on open u01"
);
assert!(
bears::graph::is_task_ready(&app.task_map, app.task_map.get("u01").unwrap()),
"u01 must still be ready"
);
}
}