Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
195 changes: 195 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,91 @@ use crate::{TdError, TdResult};
#[derive(Debug, Deserialize)]
struct ConfigFile {
todo_file: Option<PathBuf>,
sections: Option<SectionsTable>,
}

#[derive(Debug, Deserialize)]
struct SectionsTable {
tasks: Option<String>,
backlog: Option<String>,
archive: Option<String>,
}

/// 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<PathBuf> {
Expand All @@ -29,6 +114,55 @@ pub fn resolve_todo_file() -> TdResult<PathBuf> {
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<SectionHeadings> {
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<String>) -> Option<String> {
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<Option<ConfigFile>> {
let Some(home) = home_dir() else {
return Ok(None);
Expand Down Expand Up @@ -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}");
}
}
Loading