diff --git a/README.md b/README.md index 53539cb..48409d1 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,20 @@ todo_file = "/absolute/path/to/TODO.md" If the target file does not exist, commands that load it will fail with a read error. Create the file with the expected sections before using `td`. +### Custom section headings + +By default the canonical section headings are `Tasks`, `Backlog`, and `Archive`. Override any subset with an optional `[sections]` table: + +```toml +# ~/.config/td/config.toml +[sections] +tasks = "Tasks" +backlog = "Backlog" +archive = "Archive" +``` + +Keys are optional and default to the values above, so omitting `[sections]` (or any individual key) leaves behaviour unchanged. When you set custom headings, your `TODO.md` must use them as its `##` section titles; `td` then parses, migrates, and rewrites the file against the configured names. The legacy aliases `## Tâches` (→ Tasks) and `## Later` (→ Backlog) are still recognized regardless of configuration. + ## CLI reference ```bash diff --git a/src/config.rs b/src/config.rs index 536c149..dceb5d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,91 @@ use crate::{TdError, TdResult}; #[derive(Debug, Deserialize)] struct ConfigFile { todo_file: Option, + sections: Option, +} + +#[derive(Debug, Deserialize)] +struct SectionsTable { + tasks: Option, + backlog: Option, + archive: Option, +} + +/// Canonical section headings used when parsing and rendering the todo file. +/// +/// Defaults match the headings documented in the README; an optional +/// `[sections]` table in `config.toml` overrides any subset of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SectionHeadings { + pub tasks: String, + pub backlog: String, + pub archive: String, +} + +impl Default for SectionHeadings { + fn default() -> Self { + Self { + tasks: "Tasks".to_string(), + backlog: "Backlog".to_string(), + archive: "Archive".to_string(), + } + } +} + +impl SectionHeadings { + /// Reject ambiguous or unusable heading configurations before parsing. + pub fn validate(&self) -> TdResult<()> { + let headings = [ + ("tasks", &self.tasks, LegacyAlias::Tasks), + ("backlog", &self.backlog, LegacyAlias::Backlog), + ("archive", &self.archive, LegacyAlias::None), + ]; + let mut seen: Vec<(String, &'static str)> = Vec::new(); + + for (key, heading, allowed_alias) in headings { + if heading.contains('\n') || heading.contains('\r') { + return Err(TdError::Message(format!( + "invalid [sections].{key}: section headings must be single-line strings" + ))); + } + + let normalized = normalize_section_heading(heading); + if normalized.is_empty() { + return Err(TdError::Message(format!( + "invalid [sections].{key}: heading must contain letters or numbers" + ))); + } + + if let Some((_, previous_key)) = seen.iter().find(|(value, _)| *value == normalized) { + return Err(TdError::Message(format!( + "invalid [sections]: `{key}` duplicates `{previous_key}` after normalization" + ))); + } + + if normalized == "taches" && allowed_alias != LegacyAlias::Tasks { + return Err(TdError::Message(format!( + "invalid [sections].{key}: `Taches`/`Tâches` is reserved for the tasks section" + ))); + } + + if normalized == "later" && allowed_alias != LegacyAlias::Backlog { + return Err(TdError::Message(format!( + "invalid [sections].{key}: `Later` is reserved for the backlog section" + ))); + } + + seen.push((normalized, key)); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LegacyAlias { + None, + Tasks, + Backlog, } pub fn resolve_todo_file() -> TdResult { @@ -29,6 +114,55 @@ pub fn resolve_todo_file() -> TdResult { Ok(default_todo_file(&home)) } +/// Resolve the canonical section headings, applying any `[sections]` override +/// from `config.toml`. Missing keys (or no config) fall back to the defaults, +/// so behaviour with no configuration is identical to the built-in headings. +pub fn resolve_sections() -> TdResult { + let mut headings = SectionHeadings::default(); + + if let Some(config) = read_config()? + && let Some(sections) = config.sections + { + if let Some(tasks) = non_empty(sections.tasks) { + headings.tasks = tasks; + } + if let Some(backlog) = non_empty(sections.backlog) { + headings.backlog = backlog; + } + if let Some(archive) = non_empty(sections.archive) { + headings.archive = archive; + } + } + + headings.validate()?; + Ok(headings) +} + +fn non_empty(value: Option) -> Option { + value.and_then(|heading| { + let trimmed = heading.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +pub(crate) fn normalize_section_heading(value: &str) -> String { + value + .to_lowercase() + .chars() + .filter_map(|char| match char { + 'à' | 'á' | 'â' | 'ä' | 'ã' | 'å' => Some('a'), + 'ç' => Some('c'), + 'è' | 'é' | 'ê' | 'ë' => Some('e'), + 'ì' | 'í' | 'î' | 'ï' => Some('i'), + 'ò' | 'ó' | 'ô' | 'ö' | 'õ' => Some('o'), + 'ù' | 'ú' | 'û' | 'ü' => Some('u'), + 'ÿ' => Some('y'), + char if char.is_alphanumeric() => Some(char), + _ => None, + }) + .collect() +} + fn read_config() -> TdResult> { let Some(home) = home_dir() else { return Ok(None); @@ -75,4 +209,65 @@ mod tests { let home = PathBuf::from("/tmp/td-home"); assert_eq!(home.join("TODO.md"), default_todo_file("/tmp/td-home")); } + + #[test] + fn default_section_headings_match_builtin_english() { + let headings = SectionHeadings::default(); + assert_eq!(headings.tasks, "Tasks"); + assert_eq!(headings.backlog, "Backlog"); + assert_eq!(headings.archive, "Archive"); + } + + #[test] + fn sections_table_overrides_only_present_keys() { + let parsed: ConfigFile = toml::from_str( + "todo_file = \"/tmp/TODO.md\"\n[sections]\ntasks = \"Inbox\"\narchive = \"Done\"\n", + ) + .unwrap(); + let sections = parsed.sections.expect("sections table parsed"); + + // Apply the same merge logic resolve_sections() uses. + let mut headings = SectionHeadings::default(); + if let Some(tasks) = non_empty(sections.tasks) { + headings.tasks = tasks; + } + if let Some(backlog) = non_empty(sections.backlog) { + headings.backlog = backlog; + } + if let Some(archive) = non_empty(sections.archive) { + headings.archive = archive; + } + + assert_eq!(headings.tasks, "Inbox"); + assert_eq!(headings.backlog, "Backlog"); // untouched key keeps the default + assert_eq!(headings.archive, "Done"); + } + + #[test] + fn validate_rejects_duplicate_normalized_headings() { + let err = SectionHeadings { + tasks: "Inbox".to_string(), + backlog: " inbox ".to_string(), + archive: "Done".to_string(), + } + .validate() + .unwrap_err() + .to_string(); + + assert!(err.contains("duplicates `tasks`"), "{err}"); + } + + #[test] + fn validate_rejects_legacy_alias_collision() { + let err = SectionHeadings { + tasks: "Tasks".to_string(), + backlog: "Tâches".to_string(), + archive: "Archive".to_string(), + } + .validate() + .unwrap_err() + .to_string(); + + assert!(err.contains("reserved for the tasks section"), "{err}"); + } } diff --git a/src/file.rs b/src/file.rs index 9e355fa..61913f2 100644 --- a/src/file.rs +++ b/src/file.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use chrono::{Local, NaiveDate}; use fs2::FileExt; -use crate::config::home_dir; +use crate::config::{SectionHeadings, home_dir, resolve_sections}; use crate::markdown_todo_codec::MarkdownTodoCodec; use crate::task::{Task, TaskDraft}; use crate::todo_document::TodoDocument; @@ -86,6 +86,8 @@ pub struct TodoFile { pub unsupported_lines: Vec, pub(crate) migration: MigrationSet, document: TodoDocument, + /// Canonical section headings used when parsing and re-rendering this file. + headings: SectionHeadings, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -128,11 +130,22 @@ impl Drop for TodoFileLock { } impl TodoFile { - /// Load from disk, using an explicit date for migration and ID assignment. + /// Load from disk, using configured section headings and an explicit date + /// for migration and ID assignment. pub fn load_for_date(path: &Path, today: NaiveDate) -> TdResult { + let headings = resolve_sections()?; + Self::load_for_date_with_headings(path, today, &headings) + } + + /// Load from disk with explicit section headings (see [`load_for_date`]). + pub fn load_for_date_with_headings( + path: &Path, + today: NaiveDate, + headings: &SectionHeadings, + ) -> TdResult { let content = fs::read_to_string(path) .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let mut file = Self::parse(&content, today)?; + let mut file = Self::parse_with_headings(&content, today, headings)?; file.ensure_task_ids(); Ok(file) } @@ -142,13 +155,26 @@ impl TodoFile { Self::load_for_date(path, Local::now().date_naive()) } + /// Parse using the default (built-in English) section headings. pub fn parse(content: &str, today: NaiveDate) -> TdResult { - let parsed = MarkdownTodoCodec::parse(content, today)?; + Self::parse_with_headings(content, today, &SectionHeadings::default()) + } + + /// Parse using the supplied section headings, remembering them so that + /// [`render`](Self::render) and [`save`](Self::save) reproduce them. + pub fn parse_with_headings( + content: &str, + today: NaiveDate, + headings: &SectionHeadings, + ) -> TdResult { + headings.validate()?; + let parsed = MarkdownTodoCodec::parse(content, today, headings)?; Ok(Self { prefix: parsed.prefix, unsupported_lines: parsed.unsupported_lines, migration: parsed.migration, document: parsed.document, + headings: headings.clone(), }) } @@ -174,7 +200,7 @@ impl TodoFile { } pub fn render(&self) -> String { - MarkdownTodoCodec::render(&self.prefix, &self.document) + MarkdownTodoCodec::render(&self.prefix, &self.document, &self.headings) } fn ensure_safe_to_render(&self, path: &Path) -> TdResult<()> { @@ -856,4 +882,100 @@ mod tests { assert_eq!(ids_before, ids_after, "IDs unchanged"); } + + #[test] + fn default_headings_are_unchanged_english() { + // Defaults must equal the previously hardcoded English headings so that, + // with no configuration, parsing and rendering are identical to before. + let defaults = SectionHeadings::default(); + assert_eq!(defaults.tasks, "Tasks"); + assert_eq!(defaults.backlog, "Backlog"); + assert_eq!(defaults.archive, "Archive"); + + let today = NaiveDate::from_ymd_opt(2026, 5, 6).unwrap(); + let input = + "# TODO\n\n## Tasks\n\n- [ ] A \n## Backlog\n\n## Archive\n"; + let via_default = TodoFile::parse(input, today).unwrap(); + let via_explicit = TodoFile::parse_with_headings(input, today, &defaults).unwrap(); + + assert_eq!(via_default.render(), via_explicit.render()); + let rendered = via_default.render(); + assert!(rendered.contains("## Tasks")); + assert!(rendered.contains("## Backlog")); + assert!(rendered.contains("## Archive")); + } + + #[test] + fn custom_headings_round_trip_add_list_done_archive() { + let today = NaiveDate::from_ymd_opt(2026, 5, 6).unwrap(); + let headings = SectionHeadings { + tasks: "Inbox".to_string(), + backlog: "Someday".to_string(), + archive: "Done".to_string(), + }; + + let input = "# TODO\n\n## Inbox\n\n## Someday\n\n## Done\n"; + let mut file = TodoFile::parse_with_headings(input, today, &headings).unwrap(); + + // Configured headings are canonical: nothing is flagged as unsupported, + // and no migration is triggered. + assert!(file.unsupported_lines.is_empty()); + assert!(!file.has_migrations()); + + // add — one active task, one backlog task. + file.add_task(draft("Write report", false)).unwrap(); + file.add_task(draft("Learn piano", true)).unwrap(); + + // list — read each section back. + assert_eq!(file.tasks().len(), 1); + assert_eq!(file.tasks()[0].description, "Write report"); + assert_eq!(file.backlog().len(), 1); + assert_eq!(file.backlog()[0].description, "Learn piano"); + + // done — completes the active task into the archive. + file.complete_task(Section::Tasks, 0, today).unwrap(); + assert!(file.tasks().is_empty()); + assert_eq!(file.archive().len(), 1); + assert_eq!(file.archive()[0].description, "Write report"); + assert!(file.archive()[0].done); + + // render reproduces the configured headings, not the defaults. + let rendered = file.render(); + assert!(rendered.contains("## Inbox")); + assert!(rendered.contains("## Someday")); + assert!(rendered.contains("## Done")); + assert!(!rendered.contains("## Tasks")); + + // The round trip is stable and writes are accepted (no unsupported-content + // refusal) when the same headings are used. + let reparsed = TodoFile::parse_with_headings(&rendered, today, &headings).unwrap(); + assert!(reparsed.unsupported_lines.is_empty()); + assert!(!reparsed.has_migrations()); + assert_eq!(reparsed.archive().len(), 1); + + let temp = std::env::temp_dir().join("td-custom-headings-roundtrip.md"); + let _ = std::fs::remove_file(&temp); + reparsed.save(&temp).unwrap(); + let _ = std::fs::remove_file(&temp); + } + + #[test] + fn parse_with_headings_rejects_ambiguous_aliases() { + let today = NaiveDate::from_ymd_opt(2026, 5, 6).unwrap(); + let headings = SectionHeadings { + tasks: "Later".to_string(), + backlog: "Backlog".to_string(), + archive: "Archive".to_string(), + }; + + let err = TodoFile::parse_with_headings( + "# TODO\n\n## Later\n\n- [ ] A \n## Backlog\n\n## Archive\n", + today, + &headings, + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("reserved for the backlog section"), "{err}"); + } } diff --git a/src/markdown_todo_codec.rs b/src/markdown_todo_codec.rs index 747a811..35855a3 100644 --- a/src/markdown_todo_codec.rs +++ b/src/markdown_todo_codec.rs @@ -1,6 +1,7 @@ use chrono::NaiveDate; use crate::TdResult; +use crate::config::{SectionHeadings, normalize_section_heading}; use crate::file::{MigrationReason, MigrationSet, Section, UnsupportedLine}; use crate::task::Task; use crate::todo_document::TodoDocument; @@ -15,7 +16,11 @@ pub(crate) struct ParsedTodoFile { pub(crate) struct MarkdownTodoCodec; impl MarkdownTodoCodec { - pub(crate) fn parse(content: &str, today: NaiveDate) -> TdResult { + pub(crate) fn parse( + content: &str, + today: NaiveDate, + headings: &SectionHeadings, + ) -> TdResult { let mut prefix = Vec::new(); let mut tasks = Vec::new(); let mut backlog = Vec::new(); @@ -27,10 +32,13 @@ impl MarkdownTodoCodec { for (index, line) in content.lines().enumerate() { let line_number = index + 1; - if let Some(section) = parse_section_heading(line) { + if let Some(section) = parse_section_heading(line, headings) { current_section = Some(section); found_known_section = true; - if section == Section::Backlog && is_later_heading(line) { + if section == Section::Backlog + && is_later_heading(line) + && normalize_section_heading(&headings.backlog) != "later" + { migration.insert(MigrationReason::RenamedLater); } continue; @@ -114,50 +122,43 @@ impl MarkdownTodoCodec { }) } - pub(crate) fn render(prefix: &[String], document: &TodoDocument) -> String { + pub(crate) fn render( + prefix: &[String], + document: &TodoDocument, + headings: &SectionHeadings, + ) -> String { let mut lines = Vec::new(); lines.extend(trim_trailing_blank_lines(prefix.to_vec())); lines.push(String::new()); - append_section(&mut lines, "Tasks", document.tasks()); - append_section(&mut lines, "Backlog", document.backlog()); - append_section(&mut lines, "Archive", document.archive()); + append_section(&mut lines, &headings.tasks, document.tasks()); + append_section(&mut lines, &headings.backlog, document.backlog()); + append_section(&mut lines, &headings.archive, document.archive()); lines.push(String::new()); lines.join("\n") } } -fn parse_section_heading(line: &str) -> Option
{ +fn parse_section_heading(line: &str, headings: &SectionHeadings) -> Option
{ let trimmed = line.trim(); let heading = trimmed.strip_prefix("##")?.trim(); - match normalize_heading(heading).as_str() { - "tasks" | "taches" => Some(Section::Tasks), - "backlog" | "later" => Some(Section::Backlog), - "archive" => Some(Section::Archive), - _ => None, + let normalized = normalize_section_heading(heading); + + // The configured headings are canonical; `taches` (French "Tâches") and + // `later` remain recognized as legacy aliases so old files still migrate. + if normalized == normalize_section_heading(&headings.tasks) || normalized == "taches" { + Some(Section::Tasks) + } else if normalized == normalize_section_heading(&headings.backlog) || normalized == "later" { + Some(Section::Backlog) + } else if normalized == normalize_section_heading(&headings.archive) { + Some(Section::Archive) + } else { + None } } fn is_later_heading(line: &str) -> bool { let heading = line.trim().strip_prefix("##").unwrap_or(line).trim(); - normalize_heading(heading) == "later" -} - -fn normalize_heading(value: &str) -> String { - value - .to_lowercase() - .chars() - .filter_map(|char| match char { - 'à' | 'á' | 'â' | 'ä' | 'ã' | 'å' => Some('a'), - 'ç' => Some('c'), - 'è' | 'é' | 'ê' | 'ë' => Some('e'), - 'ì' | 'í' | 'î' | 'ï' => Some('i'), - 'ò' | 'ó' | 'ô' | 'ö' | 'õ' => Some('o'), - 'ù' | 'ú' | 'û' | 'ü' => Some('u'), - 'ÿ' => Some('y'), - char if char.is_alphanumeric() => Some(char), - _ => None, - }) - .collect() + normalize_section_heading(heading) == "later" } fn append_section(lines: &mut Vec, title: &str, tasks: &[Task]) { diff --git a/src/todo_repository.rs b/src/todo_repository.rs index 8b3a420..b5fcea9 100644 --- a/src/todo_repository.rs +++ b/src/todo_repository.rs @@ -27,7 +27,8 @@ impl TodoRepository { pub(crate) fn open(path: &Path) -> TdResult { let _lock = TodoFileLock::acquire(path)?; let today = Local::now().date_naive(); - let file = TodoFile::load_for_date(path, today)?; + let headings = crate::config::resolve_sections()?; + let file = TodoFile::load_for_date_with_headings(path, today, &headings)?; if file.has_migrations() { file.save(path)?; } @@ -129,7 +130,12 @@ mod tests { // Reload from disk — the migration should be persisted, so no new // migration should trigger. - let reloaded = TodoFile::load(&path).unwrap(); + let reloaded = TodoFile::load_for_date_with_headings( + &path, + Local::now().date_naive(), + &crate::config::SectionHeadings::default(), + ) + .unwrap(); let re_archived = reloaded.archive(); assert_eq!(re_archived.len(), 1); assert!(re_archived[0].completed_date.is_some());