Prepare repo for GitHub publication

- Add MIT LICENSE file
- Expand .gitignore with standard Rust/Linux entries
- Remove dangling symlinks (breadmancli, breadpadcli) and dev scratchpad (svgs.txt) from git tracking
- Replace unsafe unwrap() calls with expect() in breadman CLI (guarded by prior filter)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Breadway 2026-06-06 12:25:40 +08:00
parent feefdb81b9
commit 347508828f
34 changed files with 2825 additions and 771 deletions

View file

@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
anyhow.workspace = true
tracing.workspace = true

View file

@ -70,10 +70,9 @@ impl OllamaClient {
.into_json()
.map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?;
let classification: OllamaClassification = serde_json::from_str(&ollama_resp.response)
.map_err(|e| anyhow::anyhow!(
"parse Ollama classification JSON: {} — raw: {:?}",
e,
let classification: OllamaClassification = extract_json(&ollama_resp.response)
.ok_or_else(|| anyhow::anyhow!(
"no JSON object found in response — raw: {:?}",
&ollama_resp.response
))?;
@ -116,3 +115,12 @@ impl OllamaClient {
})
}
}
// Some backends (e.g. FastFlowLM) ignore `"format": "json"` and may wrap the
// JSON in prose. Find the first `{...}` span and parse that.
fn extract_json<T: serde::de::DeserializeOwned>(s: &str) -> Option<T> {
let start = s.find('{')?;
let end = s.rfind('}')?;
if end < start { return None; }
serde_json::from_str(&s[start..=end]).ok()
}

View file

@ -16,9 +16,14 @@ pub struct CalDavEventInfo {
impl CalDavClient {
pub fn new(config: CalendarConfig) -> Self {
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
// initialised; fall back to `Client::new()` semantics rather than panicking.
let client = reqwest::Client::builder()
.build()
.expect("failed to build HTTP client");
.unwrap_or_else(|e| {
tracing::warn!("falling back to default HTTP client: {}", e);
reqwest::Client::new()
});
CalDavClient { config, client }
}
@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String {
fn build_ical(note: &Note, uid: &str) -> String {
let dt = note.time.unwrap_or(note.created);
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
let summary = escape_ical(&note.body);
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let mut ical = format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//breadpad//EN\r\n\
BEGIN:VEVENT\r\n\
UID:{uid}\r\n\
SUMMARY:{summary}\r\n\
DTSTART:{dtstart}\r\n\
DTEND:{dtstart}\r\n\
DESCRIPTION:{description}\r\n"
);
let mut lines: Vec<String> = vec![
"BEGIN:VCALENDAR".into(),
"VERSION:2.0".into(),
"PRODID:-//breadpad//EN".into(),
"BEGIN:VEVENT".into(),
format!("UID:{}", uid),
fold_line(&format!("SUMMARY:{}", escape_ical(&note.body))),
format!("DTSTART:{}", dtstart),
format!("DTEND:{}", dtstart),
format!("DTSTAMP:{}", dtstamp),
fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
];
if let Some(rrule) = &note.rrule {
ical.push_str(rrule.as_str());
ical.push_str("\r\n");
lines.push(rrule.as_str().to_string());
}
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
ical
lines.push("END:VEVENT".into());
lines.push("END:VCALENDAR".into());
lines.join("\r\n") + "\r\n"
}
/// Fold an iCal property line per RFC 5545 §3.1: lines longer than 75 octets
/// are split with CRLF + a single space continuation character.
fn fold_line(line: &str) -> String {
let bytes = line.as_bytes();
if bytes.len() <= 75 {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + line.len() / 75 * 3);
let mut pos = 0;
let mut first = true;
while pos < bytes.len() {
if !first {
out.push_str("\r\n ");
}
let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space
let mut end = (pos + limit).min(bytes.len());
// Step back if we landed in the middle of a multi-byte UTF-8 sequence.
while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
end -= 1;
}
out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or(""));
pos = end;
first = false;
}
out
}
fn escape_ical(s: &str) -> String {
@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Note, NoteType, RecurrenceRule};
use chrono::{TimeZone, Utc};
fn reminder(body: &str) -> Note {
let mut n = Note::new(body.into(), NoteType::Reminder, None);
n.time = Some(Utc::now());
n
}
// ---- escape_ical ----
#[test]
fn escape_ical_clean_string_unchanged() {
assert_eq!(escape_ical("hello world"), "hello world");
}
#[test]
fn escape_ical_empty_string() {
assert_eq!(escape_ical(""), "");
}
#[test]
fn escape_ical_escapes_backslash() {
assert_eq!(escape_ical("back\\slash"), "back\\\\slash");
}
#[test]
fn escape_ical_escapes_semicolon() {
assert_eq!(escape_ical("a;b"), "a\\;b");
}
#[test]
fn escape_ical_escapes_comma() {
assert_eq!(escape_ical("apples,oranges"), "apples\\,oranges");
}
#[test]
fn escape_ical_escapes_newline() {
assert_eq!(escape_ical("line1\nline2"), "line1\\nline2");
}
#[test]
fn escape_ical_multiple_special_chars() {
assert_eq!(escape_ical("a;b,c\nd"), "a\\;b\\,c\\nd");
}
// ---- caldav_uid ----
#[test]
fn caldav_uid_uses_existing_field() {
let mut n = Note::new("test".into(), NoteType::Reminder, None);
n.caldav_uid = Some("my-custom-uid".into());
assert_eq!(caldav_uid(&n), "my-custom-uid");
}
#[test]
fn caldav_uid_falls_back_to_id_at_breadpad() {
let n = Note::new("test".into(), NoteType::Reminder, None);
assert_eq!(caldav_uid(&n), format!("{}@breadpad", n.id));
}
// ---- event_url ----
#[test]
fn event_url_with_trailing_slash() {
let url = event_url("https://cloud.example.com/cal/", "abc@breadpad");
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
}
#[test]
fn event_url_without_trailing_slash() {
let url = event_url("https://cloud.example.com/cal", "abc@breadpad");
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
}
// ---- build_ical ----
#[test]
fn build_ical_contains_vcalendar_markers() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("BEGIN:VCALENDAR"), "missing BEGIN:VCALENDAR");
assert!(ical.contains("END:VCALENDAR"), "missing END:VCALENDAR");
assert!(ical.contains("BEGIN:VEVENT"), "missing BEGIN:VEVENT");
assert!(ical.contains("END:VEVENT"), "missing END:VEVENT");
}
#[test]
fn build_ical_contains_uid() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains(&format!("UID:{}", uid)));
}
#[test]
fn build_ical_contains_summary() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("SUMMARY:team sync"));
}
#[test]
fn build_ical_description_contains_type() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DESCRIPTION:type=reminder"));
}
#[test]
fn build_ical_uses_note_time_for_dtstart() {
let mut n = Note::new("dentist".into(), NoteType::Reminder, None);
n.time = Some(Utc.with_ymd_and_hms(2026, 6, 15, 14, 30, 0).unwrap());
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTART:20260615T143000Z"), "ical: {}", &ical[..400]);
}
#[test]
fn build_ical_falls_back_to_created_when_no_time() {
let n = Note::new("no time set".into(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTART:"), "DTSTART should be present");
}
#[test]
fn build_ical_includes_rrule_when_set() {
let mut n = reminder("standup");
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"));
}
#[test]
fn build_ical_no_rrule_when_not_set() {
let n = reminder("one-off");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(!ical.contains("RRULE:"));
}
#[test]
fn build_ical_escapes_special_chars_in_summary() {
let n = Note::new("dentist; bring card, and ID".into(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("SUMMARY:dentist\\; bring card\\, and ID"), "ical: {}", &ical[..400]);
}
#[test]
fn build_ical_contains_dtstamp() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTAMP:"), "missing DTSTAMP in:\n{}", ical);
}
#[test]
fn fold_line_short_unchanged() {
let line = "SUMMARY:short";
assert_eq!(fold_line(line), line);
}
#[test]
fn fold_line_exactly_75_unchanged() {
let line = "A".repeat(75);
assert_eq!(fold_line(&line), line);
}
#[test]
fn fold_line_76_chars_splits() {
let line = "X".repeat(76);
let folded = fold_line(&line);
assert!(folded.contains("\r\n "), "expected fold in: {:?}", folded);
// Reassembled content should equal the original.
let rejoined: String = folded.split("\r\n ").collect();
assert_eq!(rejoined, line);
}
#[test]
fn build_ical_long_summary_is_folded() {
let long_body = "a".repeat(200);
let n = Note::new(long_body.clone(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
// Every line (split on CRLF) must be at most 75 octets.
for line in ical.split("\r\n") {
assert!(
line.len() <= 75,
"line too long ({} octets): {:?}",
line.len(),
line
);
}
}
// ---- parse_report_response ----
#[test]
fn parse_report_response_empty_xml_returns_empty() {
let events = parse_report_response("").unwrap();
assert!(events.is_empty());
}
#[test]
fn parse_report_response_single_event() {
let xml = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
UID:abc123@breadpad\r\n\
SUMMARY:team sync\r\n\
DTSTART:20260615T140000Z\r\n\
DTEND:20260615T140000Z\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let events = parse_report_response(xml).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].uid, "abc123@breadpad");
assert_eq!(events[0].summary, "team sync");
}
#[test]
fn parse_report_response_no_vcalendar_block_returns_empty() {
let xml = "<multistatus><response><status>HTTP/1.1 200 OK</status></response></multistatus>";
let events = parse_report_response(xml).unwrap();
assert!(events.is_empty());
}
}

View file

@ -9,16 +9,14 @@ const TIER1_SKIP_THRESHOLD: f32 = 0.82;
#[derive(Debug, Clone, PartialEq)]
pub enum ExecutionProvider {
Qnn,
Vulkan,
Gpu,
Cpu,
}
impl ExecutionProvider {
pub fn as_str(&self) -> &str {
match self {
ExecutionProvider::Qnn => "QNN (NPU)",
ExecutionProvider::Vulkan => "Vulkan",
ExecutionProvider::Gpu => "ROCm (iGPU)",
ExecutionProvider::Cpu => "CPU",
}
}
@ -43,20 +41,27 @@ fn model_dir() -> PathBuf {
impl Classifier {
/// Load with Tier 1 + optional Tier 2 (ONNX). Tier 3 disabled unless
/// `.with_ollama()` is called on the returned value.
pub fn load(ep_pref: &str, default_morning: &str) -> Self {
pub fn load(default_morning: &str) -> Self {
let dir = model_dir();
let onnx_path = dir.join("classifier.onnx");
let tok_path = dir.join("tokenizer.json");
Self::load_with_paths(default_morning, onnx_path, tok_path)
}
let (session, active_provider) = if onnx_path.exists() {
try_load_session(&onnx_path, ep_pref)
pub fn load_with_paths(
default_morning: &str,
model_path: PathBuf,
tokenizer_path: PathBuf,
) -> Self {
let (session, active_provider) = if model_path.exists() {
try_load_session(&model_path)
} else {
tracing::warn!("model not found at {:?}; Tier 2 disabled", onnx_path);
tracing::warn!("model not found at {:?}; Tier 2 disabled", model_path);
(None, ExecutionProvider::Cpu)
};
let tokenizer = if tok_path.exists() && session.is_some() {
match tokenizers::Tokenizer::from_file(&tok_path) {
let tokenizer = if tokenizer_path.exists() && session.is_some() {
match tokenizers::Tokenizer::from_file(&tokenizer_path) {
Ok(tok) => Some(tok),
Err(e) => {
tracing::warn!("failed to load tokenizer: {}", e);
@ -71,7 +76,7 @@ impl Classifier {
session,
tokenizer,
active_provider,
model_path: onnx_path,
model_path,
default_morning: default_morning.to_string(),
ollama: None,
}
@ -144,6 +149,13 @@ impl Classifier {
pub fn model_available(&self) -> bool {
self.session.is_some()
}
/// Run only the ONNX model (Tier 2) with no Tier 1 pre-processing or fallback.
/// Returns `None` if no model is loaded.
pub fn classify_tier2_only(&mut self, text: &str) -> Option<ClassificationResult> {
let (session, tokenizer) = (self.session.as_mut()?, self.tokenizer.as_ref()?);
run_onnx(session, tokenizer, text).ok()
}
}
// NLI hypotheses paired with their note types. The model scores each as
@ -204,7 +216,7 @@ fn run_onnx(
let best_idx = entailment_scores
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
.map(|(i, _)| i)
.unwrap_or(3);
@ -233,52 +245,34 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
fn try_load_session(
path: &std::path::Path,
ep_pref: &str,
) -> (Option<ort::session::Session>, ExecutionProvider) {
let providers: &[(&str, ExecutionProvider)] = &[
("qnn", ExecutionProvider::Qnn),
("vulkan", ExecutionProvider::Vulkan),
("cpu", ExecutionProvider::Cpu),
];
let to_try: Vec<&(&str, ExecutionProvider)> = match ep_pref {
"npu" => providers[..1].iter().collect(),
"vulkan" => providers[1..2].iter().collect(),
"cpu" => providers[2..].iter().collect(),
_ => providers.iter().collect(),
};
for (ep_name, ep) in to_try {
match build_session(path, ep_name) {
Ok(session) => {
tracing::info!("ONNX session loaded with {} EP", ep.as_str());
return (Some(session), ep.clone());
}
Err(e) => {
tracing::debug!("{} EP unavailable: {}", ep_name, e);
}
// Try ROCm (iGPU) first, fall back to CPU.
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
Ok(s) => {
tracing::info!("ONNX session loaded (ROCm iGPU)");
return (Some(s), ExecutionProvider::Gpu);
}
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
}
match build_onnx_session(path, ort::ep::CPU::default().build()) {
Ok(s) => {
tracing::info!("ONNX session loaded (CPU)");
(Some(s), ExecutionProvider::Cpu)
}
Err(e) => {
tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e);
(None, ExecutionProvider::Cpu)
}
}
(None, ExecutionProvider::Cpu)
}
fn build_session(
fn build_onnx_session(
path: &std::path::Path,
ep_name: &str,
ep: ort::ep::ExecutionProviderDispatch,
) -> anyhow::Result<ort::session::Session> {
match ep_name {
"cpu" => {
let builder = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("builder: {}", e))?;
let mut builder = builder
.with_execution_providers([ort::ep::CPU::default().build()])
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
let session = builder
.commit_from_file(path)
.map_err(|e| anyhow::anyhow!("load: {}", e))?;
Ok(session)
}
_ => Err(anyhow::anyhow!("EP '{}' not available in this build", ep_name)),
}
let mut builder = ort::session::Session::builder()
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
.with_execution_providers([ep])
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
}

View file

@ -11,15 +11,29 @@ fn default_snooze_options() -> Vec<String> {
fn default_archive_after_days() -> i64 { 30 }
fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() }
fn default_tokenizer_path() -> String { "~/.local/share/breadpad/model/tokenizer.json".into() }
fn default_execution_provider() -> String { "auto".into() }
fn default_ort_dylib_path() -> String { "".into() }
fn default_morning_time() -> String { "08:00".into() }
fn default_missed_grace_minutes() -> i64 { 60 }
fn default_ollama_endpoint() -> String { "http://localhost:11434".into() }
fn default_ollama_model() -> String { "llama3.2:3b".into() }
fn default_ollama_model() -> String { "fastflowlm".into() }
fn default_ollama_confidence_threshold() -> f32 { 0.6 }
fn default_ollama_enabled() -> bool { true }
fn default_calendar_enabled() -> bool { false }
pub fn expand_path(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
PathBuf::from(path)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_type_str")]
@ -72,8 +86,9 @@ pub struct ModelConfig {
pub path: String,
#[serde(default = "default_tokenizer_path")]
pub tokenizer: String,
#[serde(default = "default_execution_provider")]
pub execution_provider: String,
/// Path to `libonnxruntime.so`. Auto-discovered when empty.
#[serde(default = "default_ort_dylib_path")]
pub ort_dylib_path: String,
#[serde(default)]
pub ollama: OllamaConfig,
}
@ -83,12 +98,26 @@ impl Default for ModelConfig {
ModelConfig {
path: default_model_path(),
tokenizer: default_tokenizer_path(),
execution_provider: default_execution_provider(),
ort_dylib_path: default_ort_dylib_path(),
ollama: OllamaConfig::default(),
}
}
}
impl ModelConfig {
pub fn resolved_paths(&self) -> (PathBuf, PathBuf) {
(expand_path(&self.path), expand_path(&self.tokenizer))
}
pub fn resolved_ort_dylib_path(&self) -> Option<PathBuf> {
let raw = self.ort_dylib_path.trim();
if raw.is_empty() {
return None;
}
Some(expand_path(raw))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemindersConfig {
#[serde(default = "default_morning_time")]
@ -114,6 +143,9 @@ pub struct CalendarConfig {
pub url: String,
#[serde(default)]
pub username: String,
/// WARNING: stored as plaintext in breadpad.toml. Restrict the file's permissions
/// (`chmod 600 ~/.config/breadpad/breadpad.toml`) and keep it out of version control.
/// A future release may support reading the password from the OS secret service instead.
#[serde(default)]
pub password: String,
}

View file

@ -7,3 +7,4 @@ pub mod scheduler;
pub mod store;
pub mod theme;
pub mod types;
pub mod util;

View file

@ -1,4 +1,5 @@
use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
use crate::util::local_naive_to_utc;
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
use regex::Regex;
use std::sync::OnceLock;
@ -22,7 +23,7 @@ static PATTERNS: OnceLock<Patterns> = OnceLock::new();
fn patterns() -> &'static Patterns {
PATTERNS.get_or_init(|| Patterns {
at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(),
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(minute|hour|day)s?").unwrap(),
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(second|minute|hour|day|week)s?").unwrap(),
// Word-form durations: "in an hour", "in a couple of hours", "in half an hour"
in_duration_word: Regex::new(
r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)"
@ -100,7 +101,7 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
};
let target_date = local.date_naive() + Duration::days(days_ahead);
let naive = target_date.and_time(time);
naive.and_local_timezone(Local).unwrap().with_timezone(&Utc)
local_naive_to_utc(naive)
}
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
@ -209,7 +210,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
} else {
(local.date_naive() + Duration::days(1)).and_time(t)
};
extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc));
extracted_time = Some(local_naive_to_utc(naive));
let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
}
@ -218,9 +219,11 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
let n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1);
let unit = caps.get(2).unwrap().as_str().to_lowercase();
let delta = match unit.as_str() {
"second" => Duration::seconds(n),
"minute" => Duration::minutes(n),
"hour" => Duration::hours(n),
"day" => Duration::days(n),
"week" => Duration::weeks(n),
_ => Duration::minutes(n),
};
extracted_time = Some(Utc::now() + delta);
@ -254,7 +257,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
};
let local = Local::now();
let target = (local.date_naive() + Duration::days(1)).and_time(t);
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
extracted_time = Some(local_naive_to_utc(target));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
}
// One-off: next <weekday>
@ -273,7 +276,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
} else {
(local.date_naive() + Duration::days(1)).and_time(anchor)
};
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
extracted_time = Some(local_naive_to_utc(target));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
}
}
@ -860,6 +863,23 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|| lower.starts_with("finish ")
|| lower.starts_with("write ")
|| lower.starts_with("update ")
|| lower.starts_with("prepare ")
|| lower.starts_with("schedule ")
|| lower.starts_with("organize ")
|| lower.starts_with("deploy ")
|| lower.starts_with("install ")
|| lower.starts_with("send ")
|| lower.starts_with("submit ")
|| lower.starts_with("create ")
|| lower.starts_with("setup ")
|| lower.starts_with("restore ")
|| lower.starts_with("archive ")
|| lower.starts_with("export ")
|| lower.starts_with("import ")
|| lower.starts_with("approve ")
|| lower.starts_with("configure ")
|| lower.starts_with("refactor ")
|| lower.starts_with("review ")
{
return NoteType::Todo;
}
@ -867,13 +887,21 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|| lower.starts_with("idea:")
|| lower.contains("could ")
|| lower.contains("maybe ")
|| lower.contains("should we ")
|| lower.starts_with("should we ")
{
return NoteType::Idea;
}
if lower.starts_with("why ")
|| lower.starts_with("how ")
|| lower.starts_with("what ")
|| (lower.starts_with("what ") && !lower.starts_with("what if "))
|| lower.starts_with("when ")
|| lower.starts_with("where ")
|| lower.starts_with("who ")
|| lower.starts_with("will ")
|| lower.starts_with("is ")
|| lower.starts_with("are ")
|| lower.starts_with("did ")
|| lower.starts_with("does ")
|| lower.ends_with('?')
{
return NoteType::Question;

View file

@ -1,4 +1,5 @@
use crate::types::Note;
use crate::util::local_naive_to_utc;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
use std::process::Command;
@ -59,27 +60,63 @@ fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
let timer_name = timer_unit_name(id);
// Find the breadpad binary. Order of preference:
// 1. $BREADPAD_BIN override,
// 2. a `breadpad` next to the currently running executable,
// 3. standard install locations.
let breadpad_exe = std::env::var_os("BREADPAD_BIN")
.map(std::path::PathBuf::from)
.filter(|p| p.exists())
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|p| p.join("breadpad")))
.filter(|p| p.exists())
})
.or_else(|| {
let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad"));
["/usr/local/bin/breadpad", "/usr/bin/breadpad"]
.iter()
.map(std::path::PathBuf::from)
.chain(home_bin)
.find(|p| p.exists())
})
.context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?;
// Use systemd-run to create both service + timer as a transient unit
let status = Command::new("systemd-run")
.arg("--user")
// Pass necessary environment variables for notifications to work
let mut cmd = Command::new("systemd-run");
cmd.arg("--user")
.arg("--unit")
.arg(&timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
.arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
.arg("--timer-property")
.arg(format!("OnCalendar={}", on_calendar))
.arg("--timer-property")
.arg("Persistent=true")
.arg("--")
.arg("breadpad")
.arg("Persistent=true");
// Pass DBUS and display environment variables so notify-send works
if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus));
}
if let Ok(display) = std::env::var("DISPLAY") {
cmd.arg("--setenv").arg(format!("DISPLAY={}", display));
}
if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland));
}
cmd.arg("--")
.arg(&breadpad_exe)
.arg("fire")
.arg(id)
.status()
.context("failed to run systemd-run")?;
.arg(id);
let status = cmd.status().context("failed to run systemd-run")?;
if !status.success() {
anyhow::bail!("systemd-run failed for reminder {}", id);
}
tracing::info!("scheduled reminder {} at {}", id, on_calendar);
tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display());
Ok(())
}
@ -131,17 +168,15 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
let now = Local::now();
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
let next = match freq {
match freq {
"DAILY" => {
let today = now.date_naive().and_time(fire_time);
if now.naive_local() < today {
today.and_local_timezone(Local).unwrap()
let naive = if now.naive_local() < today {
today
} else {
(now.date_naive() + chrono::Duration::days(1))
.and_time(fire_time)
.and_local_timezone(Local)
.unwrap()
}
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
};
return Some(local_naive_to_utc(naive));
}
"WEEKLY" => {
use chrono::Datelike;
@ -169,12 +204,10 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
};
let target_date =
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
target_date.and_local_timezone(Local).unwrap()
return Some(local_naive_to_utc(target_date));
}
_ => return None,
};
Some(next.with_timezone(&Utc))
_ => None,
}
}
#[cfg(test)]
@ -331,6 +364,21 @@ mod tests {
assert_eq!(local.weekday(), chrono::Weekday::Sat);
}
#[test]
fn weekly_tuesday_is_tuesday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Tue);
}
#[test]
fn weekly_thursday_is_thursday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TH;BYHOUR=11;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Thu);
assert_eq!(local.minute(), 30);
}
#[test]
fn weekly_sunday_is_sunday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
@ -338,6 +386,22 @@ mod tests {
assert_eq!(local.weekday(), chrono::Weekday::Sun);
}
#[test]
fn weekly_unknown_byday_falls_back_to_sunday() {
// The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Sun);
}
#[test]
fn daily_without_byhour_uses_default_morning() {
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "06:45").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.hour(), 6);
assert_eq!(local.minute(), 45);
}
#[test]
fn unknown_freq_returns_none() {
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());

View file

@ -36,6 +36,14 @@ impl Store {
self
}
pub fn with_calendar_if_enabled(self, cfg: &crate::config::Config) -> Self {
if cfg.calendar.enabled {
self.with_calendar(cfg.calendar.clone())
} else {
self
}
}
pub fn load_all(&self) -> Result<Vec<Note>> {
self.load_from(&self.notes_path)
}
@ -84,12 +92,14 @@ impl Store {
pub fn update_note(&self, updated: &Note) -> Result<()> {
self.rewrite_notes(|note| {
if note.id == updated.id {
updated.clone()
} else {
note
if note.id == updated.id { updated.clone() } else { note }
})?;
if let Some(cal_cfg) = &self.calendar {
if cal_cfg.enabled && (updated.time.is_some() || updated.rrule.is_some()) {
spawn_caldav_push(updated.clone(), cal_cfg.clone());
}
})
}
Ok(())
}
pub fn delete_note(&self, id: &str) -> Result<()> {

View file

@ -91,19 +91,23 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
@define-color teal {c6};
@define-color overlay {c0};
* {{
font-family: 'Varela Round', sans-serif;
}}
window {{
background-color: @bg;
color: @fg;
border-radius: 12px;
border-radius: 8px;
}}
.popup-entry {{
background: @bg;
color: @fg;
border: 2px solid @blue;
border-radius: 8px;
border-radius: 6px;
padding: 12px 16px;
font-size: 16px;
font-size: 14px;
caret-color: @fg;
}}
@ -116,9 +120,9 @@ window {{
background: @overlay;
color: @fg;
border-radius: 999px;
padding: 2px 10px;
padding: 4px 12px;
font-size: 12px;
margin: 2px;
margin: 4px;
}}
.type-chip.active {{
@ -127,7 +131,7 @@ window {{
}}
.confirm-button {{
background: @green;
background: @blue;
color: @bg;
border: none;
border-radius: 8px;
@ -139,7 +143,7 @@ window {{
background: shade(@bg, 1.1);
border-radius: 8px;
padding: 12px;
margin: 4px 8px;
margin: 8px;
border-left: 3px solid @blue;
}}
@ -151,9 +155,13 @@ window {{
background: shade(@bg, 1.1);
color: @fg;
border: 1px solid @overlay;
border-radius: 8px;
border-radius: 6px;
padding: 8px 12px;
margin: 8px;
}}
.search-entry:focus {{
border-color: @blue;
outline: none;
}}
"#,
bg = palette.background,
@ -178,25 +186,27 @@ window {{
}
.sidebar-row {
padding: 6px 12px;
padding: 8px 12px;
font-size: 14px;
transition: background 100ms ease;
}
.sidebar-row:hover:not(:selected) {
background: shade(@bg, 1.08);
background: shade(@bg, 1.1);
}
.sidebar-row:selected {
background: @blue;
color: @bg;
font-weight: 500;
}
.sidebar-section-label {
color: alpha(@fg, 0.4);
font-size: 10px;
font-weight: bold;
padding: 10px 14px 2px 14px;
letter-spacing: 1px;
color: alpha(@fg, 0.5);
font-size: 11px;
font-weight: 600;
padding: 12px 12px 8px 12px;
letter-spacing: 0.5px;
}
.action-btn {
@ -228,6 +238,62 @@ window {{
.note-card-question { border-left-color: @teal; }
.note-card-note { border-left-color: @blue; }
.reminder-window {
background: @bg;
border: 1px solid @overlay;
border-radius: 8px;
}
.reminder-emoji { font-size: 20px; }
.reminder-title {
font-size: 12px;
font-weight: bold;
color: alpha(@fg, 0.6);
letter-spacing: 0.5px;
}
.reminder-time {
font-size: 12px;
color: alpha(@fg, 0.5);
}
.reminder-body {
font-size: 18px;
font-weight: bold;
color: @fg;
}
.reminder-dismiss {
background: transparent;
border: 1px solid @overlay;
border-radius: 8px;
padding: 8px 16px;
color: alpha(@fg, 0.6);
}
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
.reminder-snooze {
background: transparent;
border: 1px solid @overlay;
border-radius: 8px;
padding: 8px 16px;
color: @fg;
}
.reminder-snooze:hover { background: shade(@bg, 1.1); }
.snooze-option {
background: transparent;
border: none;
border-radius: 6px;
padding: 8px 12px;
color: @fg;
}
.snooze-option:hover { background: shade(@bg, 1.2); }
entry {
background: shade(@bg, 1.1);
color: @fg;

View file

@ -72,7 +72,9 @@ pub struct Note {
pub done: bool,
pub workspace: Option<String>,
pub created: DateTime<Utc>,
#[serde(default)]
pub snoozed_until: Option<DateTime<Utc>>,
#[serde(default)]
pub completed: Option<DateTime<Utc>>,
#[serde(default)]
pub tags: Vec<String>,
@ -83,10 +85,14 @@ pub struct Note {
impl Note {
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
Note {
// 12 hex chars (~48 bits) keeps IDs short and human-typable while making
// collisions vanishingly unlikely — important because update/delete/get_by_id
// all match notes purely by this id.
id: uuid::Uuid::new_v4()
.simple()
.to_string()
.chars()
.take(6)
.take(12)
.collect(),
body,
note_type,
@ -250,10 +256,15 @@ mod tests {
}
#[test]
fn note_id_is_six_chars() {
fn note_id_is_twelve_chars() {
for _ in 0..50 {
let note = Note::new("x".into(), NoteType::Note, None);
assert_eq!(note.id.len(), 6, "id '{}' is not 6 chars", note.id);
assert_eq!(note.id.len(), 12, "id '{}' is not 12 chars", note.id);
assert!(
note.id.chars().all(|c| c.is_ascii_hexdigit()),
"id '{}' is not all hex",
note.id
);
}
}

View file

@ -0,0 +1,63 @@
use chrono::{DateTime, Duration, Local, LocalResult, NaiveDateTime, TimeZone, Utc};
/// Resolve a naive *local* datetime to UTC without panicking on DST transitions.
///
/// `NaiveDateTime::and_local_timezone` (and `Local.from_local_datetime`) returns a
/// `LocalResult`, which is not always `Single`:
/// - `Single` — the normal case.
/// - `Ambiguous` (a fall-back hour that occurs twice) — pick the earliest instant.
/// - `None` (a spring-forward gap where the wall-clock time never happens) — advance
/// an hour at a time until a valid instant is found, then fall back to treating the
/// naive value as UTC.
///
/// Calling `.unwrap()` on the `None`/`Ambiguous` cases panics, which is what this helper
/// exists to avoid (it bit us on the ~2 DST transition days per year).
pub fn local_naive_to_utc(naive: NaiveDateTime) -> DateTime<Utc> {
match Local.from_local_datetime(&naive) {
LocalResult::Single(dt) => dt.with_timezone(&Utc),
LocalResult::Ambiguous(earliest, _latest) => earliest.with_timezone(&Utc),
LocalResult::None => {
let mut shifted = naive;
for _ in 0..3 {
shifted += Duration::hours(1);
if let LocalResult::Single(dt) = Local.from_local_datetime(&shifted) {
return dt.with_timezone(&Utc);
}
}
// Last resort: interpret the wall-clock value as UTC so we still return a time.
Utc.from_utc_datetime(&naive)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
#[test]
fn ordinary_time_round_trips() {
let naive = NaiveDate::from_ymd_opt(2026, 6, 15)
.unwrap()
.and_hms_opt(9, 30, 0)
.unwrap();
let utc = local_naive_to_utc(naive);
// Converting back to local should yield the same wall-clock time.
let local: DateTime<Local> = utc.with_timezone(&Local);
assert_eq!(local.naive_local(), naive);
}
#[test]
fn never_panics_across_a_full_year_of_hours() {
// Walk every hour of a year through the helper; it must never panic regardless
// of the host timezone's DST rules.
let mut dt = NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap();
for _ in 0..(366 * 24) {
let _ = local_naive_to_utc(dt);
dt += Duration::hours(1);
}
}
}

View file

@ -1,16 +1,27 @@
use breadpad_shared::classifier::Classifier;
use breadpad_shared::classifier::{Classifier, ExecutionProvider};
use breadpad_shared::types::NoteType;
use chrono::Timelike;
fn cl() -> Classifier {
Classifier::load("auto", "08:00")
Classifier::load("08:00")
}
#[test]
fn active_provider_is_cpu() {
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback.
fn active_provider_is_valid() {
// The active provider depends on the host: a machine with the ONNX model present and
// a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
// model is available we must be on CPU (no session => no GPU EP in use).
let c = cl();
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu);
assert!(matches!(
c.active_provider,
ExecutionProvider::Cpu | ExecutionProvider::Gpu
));
if !c.model_available() {
assert!(
matches!(c.active_provider, ExecutionProvider::Cpu),
"no model loaded but provider was not CPU"
);
}
}
#[test]
@ -63,7 +74,7 @@ fn classify_recurrence_via_fallback() {
#[test]
fn classify_custom_morning_time() {
let mut c = Classifier::load("auto", "07:15");
let mut c = Classifier::load("07:15");
let r = c.classify("sync tomorrow morning");
let t = r.time.expect("should have a time for tomorrow morning");
let local: chrono::DateTime<chrono::Local> = t.into();
@ -71,6 +82,41 @@ fn classify_custom_morning_time() {
assert_eq!(local.minute(), 15);
}
#[test]
fn classify_empty_string_does_not_panic() {
let mut c = cl();
let _ = c.classify("");
}
#[test]
fn classify_whitespace_only_does_not_panic() {
let mut c = cl();
let _ = c.classify(" ");
}
#[test]
fn classify_in_duration_sets_time() {
let mut c = cl();
let r = c.classify("take a break in 30 minutes");
assert!(r.time.is_some(), "should have a time for 'in 30 minutes'");
assert_eq!(r.note_type, NoteType::Reminder);
}
#[test]
fn classify_tomorrow_sets_time() {
let mut c = cl();
let r = c.classify("submit the invoice tomorrow");
assert!(r.time.is_some(), "tomorrow should produce a scheduled time");
}
#[test]
fn classify_returns_cleaned_body() {
let mut c = cl();
let r = c.classify("call mum at 6pm");
assert!(r.body.contains("call mum"), "body: {}", r.body);
assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
}
#[test]
fn model_path_points_to_expected_location() {
let c = cl();

View file

@ -1,4 +1,4 @@
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings};
use breadpad_shared::config::{expand_path, CalendarConfig, Config, ModelConfig, RemindersConfig, Settings};
use tempfile::TempDir;
// ---- Default values ----
@ -22,9 +22,9 @@ fn default_snooze_options_contains_all_three() {
#[test]
fn default_model_config() {
let m = ModelConfig::default();
assert_eq!(m.execution_provider, "auto");
assert!(m.path.contains("classifier.onnx"));
assert!(m.tokenizer.contains("tokenizer.json"));
assert_eq!(m.ort_dylib_path, "");
}
#[test]
@ -38,7 +38,6 @@ fn default_reminders_config() {
fn default_config_composes_defaults() {
let cfg = Config::default();
assert_eq!(cfg.settings.default_type, "note");
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00");
}
@ -56,7 +55,7 @@ archive_after_days = 7
[model]
path = "/tmp/classifier.onnx"
tokenizer = "/tmp/tokenizer.json"
execution_provider = "cpu"
ort_dylib_path = "/tmp/libonnxruntime.so"
[reminders]
default_morning = "07:30"
@ -67,8 +66,8 @@ missed_grace_minutes = 30
assert!(!cfg.settings.workspace_tag);
assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]);
assert_eq!(cfg.settings.archive_after_days, 7);
assert_eq!(cfg.model.execution_provider, "cpu");
assert_eq!(cfg.model.path, "/tmp/classifier.onnx");
assert_eq!(cfg.model.ort_dylib_path, "/tmp/libonnxruntime.so");
assert_eq!(cfg.reminders.default_morning, "07:30");
assert_eq!(cfg.reminders.missed_grace_minutes, 30);
}
@ -78,7 +77,6 @@ fn empty_toml_uses_all_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.settings.default_type, "note");
assert!(cfg.settings.workspace_tag);
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00");
}
@ -90,31 +88,9 @@ default_type = "reminder"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.settings.default_type, "reminder");
// Other sections should still have defaults
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00");
}
#[test]
fn partial_toml_only_model_section() {
let toml = r#"
[model]
execution_provider = "npu"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.model.execution_provider, "npu");
assert_eq!(cfg.settings.default_type, "note");
}
#[test]
fn execution_provider_variants_accepted() {
for ep in &["auto", "npu", "vulkan", "cpu"] {
let toml = format!("[model]\nexecution_provider = \"{}\"", ep);
let cfg: Config = toml::from_str(&toml).unwrap();
assert_eq!(cfg.model.execution_provider, *ep);
}
}
// ---- TOML serialization round-trip ----
#[test]
@ -124,7 +100,6 @@ fn default_config_serializes_to_valid_toml() {
let reparsed: Config = toml::from_str(&serialized).unwrap();
assert_eq!(reparsed.settings.default_type, cfg.settings.default_type);
assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag);
assert_eq!(reparsed.model.execution_provider, cfg.model.execution_provider);
assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning);
}
@ -133,7 +108,6 @@ fn custom_config_round_trips() {
let mut cfg = Config::default();
cfg.settings.default_type = "idea".into();
cfg.settings.archive_after_days = 14;
cfg.model.execution_provider = "vulkan".into();
cfg.reminders.default_morning = "06:45".into();
cfg.reminders.missed_grace_minutes = 120;
@ -141,7 +115,6 @@ fn custom_config_round_trips() {
let rt: Config = toml::from_str(&toml).unwrap();
assert_eq!(rt.settings.default_type, "idea");
assert_eq!(rt.settings.archive_after_days, 14);
assert_eq!(rt.model.execution_provider, "vulkan");
assert_eq!(rt.reminders.default_morning, "06:45");
assert_eq!(rt.reminders.missed_grace_minutes, 120);
}
@ -155,24 +128,20 @@ fn save_and_load_round_trip() {
let mut cfg = Config::default();
cfg.settings.default_type = "question".into();
cfg.model.execution_provider = "cpu".into();
cfg.reminders.missed_grace_minutes = 45;
// Manually save to a known path (Config::save uses the fixed XDG path,
// so we use toml serialization + write here to test the round-trip logic)
let toml = toml::to_string_pretty(&cfg).unwrap();
std::fs::write(&config_path, &toml).unwrap();
let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(loaded.settings.default_type, "question");
assert_eq!(loaded.model.execution_provider, "cpu");
assert_eq!(loaded.reminders.missed_grace_minutes, 45);
}
// ---- The example from the README ----
#[test]
fn readme_example_toml_parses() {
fn example_toml_parses() {
let toml = r#"
[settings]
default_type = "note"
@ -183,7 +152,7 @@ archive_after_days = 30
[model]
path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
execution_provider = "auto"
ort_dylib_path = ""
[reminders]
default_morning = "08:00"
@ -192,7 +161,146 @@ missed_grace_minutes = 60
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.settings.default_type, "note");
assert!(cfg.settings.workspace_tag);
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00");
assert_eq!(cfg.reminders.missed_grace_minutes, 60);
}
// ---- CalendarConfig ----
#[test]
fn default_calendar_config_is_disabled() {
let c = CalendarConfig::default();
assert!(!c.enabled);
assert!(c.url.is_empty());
assert!(c.username.is_empty());
assert!(c.password.is_empty());
}
#[test]
fn calendar_config_from_toml() {
let toml = r#"
[calendar]
enabled = true
url = "https://cloud.example.com/remote.php/dav/calendars/user/personal/"
username = "user"
password = "secret"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(cfg.calendar.enabled);
assert!(cfg.calendar.url.contains("dav/calendars"));
assert_eq!(cfg.calendar.username, "user");
assert_eq!(cfg.calendar.password, "secret");
}
#[test]
fn calendar_config_round_trips() {
let mut cfg = Config::default();
cfg.calendar.enabled = true;
cfg.calendar.url = "https://example.com/cal".into();
cfg.calendar.username = "alice".into();
cfg.calendar.password = "hunter2".into();
let toml = toml::to_string_pretty(&cfg).unwrap();
let rt: Config = toml::from_str(&toml).unwrap();
assert!(rt.calendar.enabled);
assert_eq!(rt.calendar.url, "https://example.com/cal");
assert_eq!(rt.calendar.username, "alice");
assert_eq!(rt.calendar.password, "hunter2");
}
#[test]
fn default_config_calendar_disabled() {
let cfg = Config::default();
assert!(!cfg.calendar.enabled);
}
// ---- OllamaConfig ----
#[test]
fn default_ollama_config_enabled() {
let m = ModelConfig::default();
assert!(m.ollama.enabled);
assert_eq!(m.ollama.endpoint, "http://localhost:11434");
assert!(!m.ollama.model.is_empty());
assert!(m.ollama.confidence_threshold > 0.0 && m.ollama.confidence_threshold <= 1.0);
}
#[test]
fn ollama_config_from_toml() {
let toml = r#"
[model.ollama]
enabled = false
endpoint = "http://localhost:9999"
model = "llama3"
confidence_threshold = 0.8
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(!cfg.model.ollama.enabled);
assert_eq!(cfg.model.ollama.endpoint, "http://localhost:9999");
assert_eq!(cfg.model.ollama.model, "llama3");
assert!((cfg.model.ollama.confidence_threshold - 0.8).abs() < 1e-5);
}
// ---- expand_path ----
#[test]
fn expand_path_tilde_prefix_replaced_with_home() {
let home = dirs::home_dir().unwrap();
let expanded = expand_path("~/some/path");
assert!(expanded.starts_with(&home));
assert!(expanded.ends_with("some/path"));
}
#[test]
fn expand_path_bare_tilde_is_home() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_path("~"), home);
}
#[test]
fn expand_path_absolute_path_unchanged() {
let p = expand_path("/usr/local/bin/breadpad");
assert_eq!(p.to_str().unwrap(), "/usr/local/bin/breadpad");
}
#[test]
fn expand_path_relative_path_unchanged() {
let p = expand_path("relative/path");
assert_eq!(p.to_str().unwrap(), "relative/path");
}
// ---- ModelConfig::resolved_ort_dylib_path ----
#[test]
fn resolved_ort_dylib_empty_returns_none() {
let m = ModelConfig::default();
assert!(m.resolved_ort_dylib_path().is_none());
}
#[test]
fn resolved_ort_dylib_whitespace_only_returns_none() {
let mut m = ModelConfig::default();
m.ort_dylib_path = " ".into();
assert!(m.resolved_ort_dylib_path().is_none());
}
#[test]
fn resolved_ort_dylib_set_returns_some() {
let mut m = ModelConfig::default();
m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into();
assert_eq!(
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
"/usr/lib/libonnxruntime.so"
);
}
// ---- ModelConfig::resolved_paths ----
#[test]
fn resolved_paths_expands_tildes() {
let m = ModelConfig::default();
let (model, tokenizer) = m.resolved_paths();
let home = dirs::home_dir().unwrap();
assert!(model.starts_with(&home), "model path should be under home: {:?}", model);
assert!(tokenizer.starts_with(&home), "tokenizer path should be under home: {:?}", tokenizer);
}

View file

@ -14,7 +14,7 @@ use tempfile::TempDir;
// Mirrors commit_note() in breadpad/src/main.rs.
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
let mut classifier = Classifier::load("auto", "08:00");
let mut classifier = Classifier::load("08:00");
let result = classifier.classify(text);
let mut note = Note::new(text.into(), user_type.clone(), None);

View file

@ -301,6 +301,51 @@ fn rotate_archive_zero_when_nothing_qualifies() {
assert_eq!(store.load_all().unwrap().len(), 1);
}
#[test]
fn rotate_archive_note_just_inside_boundary_stays() {
let (_dir, store) = mk();
// 29 days ago — threshold is 30 — should NOT be archived
let mut n = note("fresh enough", NoteType::Todo);
n.done = true;
n.completed = Some(Utc::now() - Duration::days(29));
store.save_note(&n).unwrap();
assert_eq!(store.rotate_archive(30).unwrap(), 0);
assert_eq!(store.load_all().unwrap().len(), 1);
}
#[test]
fn rotate_archive_note_just_past_boundary_is_archived() {
let (_dir, store) = mk();
// 31 days ago — threshold is 30 — should be archived
let mut n = note("old enough", NoteType::Todo);
n.done = true;
n.completed = Some(Utc::now() - Duration::days(31));
store.save_note(&n).unwrap();
assert_eq!(store.rotate_archive(30).unwrap(), 1);
assert!(store.load_all().unwrap().is_empty());
assert_eq!(store.load_archive().unwrap().len(), 1);
}
#[test]
fn rotate_archive_zero_day_threshold_archives_completed_notes() {
let (_dir, store) = mk();
let mut done = note("done a second ago", NoteType::Todo);
done.done = true;
done.completed = Some(Utc::now() - Duration::seconds(1));
store.save_note(&done).unwrap();
let undone = note("still active", NoteType::Todo);
store.save_note(&undone).unwrap();
assert_eq!(store.rotate_archive(0).unwrap(), 1);
let remaining = store.load_all().unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].body, "still active");
assert_eq!(store.load_archive().unwrap().len(), 1);
}
#[test]
fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
let (_dir, store) = mk();