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)
This commit is contained in:
Breadway 2026-06-06 12:25:40 +08:00
parent feefdb81b9
commit c4626dd64d
34 changed files with 2825 additions and 771 deletions

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();