Committing before copilot touches this
This commit is contained in:
commit
feefdb81b9
36 changed files with 12338 additions and 0 deletions
29
breadpad-shared/Cargo.toml
Normal file
29
breadpad-shared/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "breadpad-shared"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
rrule.workspace = true
|
||||
tokio.workspace = true
|
||||
zbus.workspace = true
|
||||
ort.workspace = true
|
||||
tokenizers.workspace = true
|
||||
ndarray.workspace = true
|
||||
toml.workspace = true
|
||||
dirs.workspace = true
|
||||
regex.workspace = true
|
||||
ureq.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
ical = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
118
breadpad-shared/src/ai.rs
Normal file
118
breadpad-shared/src/ai.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use crate::config::OllamaConfig;
|
||||
use crate::types::{ClassificationResult, NoteType};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct OllamaClient {
|
||||
endpoint: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaGenerateResponse {
|
||||
response: String,
|
||||
#[allow(dead_code)]
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaClassification {
|
||||
#[serde(rename = "type")]
|
||||
note_type: Option<String>,
|
||||
body: Option<String>,
|
||||
confidence: Option<f32>,
|
||||
}
|
||||
|
||||
impl OllamaClient {
|
||||
pub fn new(cfg: &OllamaConfig) -> Self {
|
||||
OllamaClient {
|
||||
endpoint: cfg.endpoint.trim_end_matches('/').to_string(),
|
||||
model: cfg.model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run Tier 3 classification. Returns `fallback` if Ollama is unreachable or returns
|
||||
/// an unparseable response. Time, rrule, and body from Tier 1 are always preserved.
|
||||
pub fn classify(&self, text: &str, fallback: &ClassificationResult) -> ClassificationResult {
|
||||
match self.try_classify(text, fallback) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Tier 3 (Ollama) unavailable: {}; using Tier 2 result", e);
|
||||
fallback.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_classify(&self, text: &str, fallback: &ClassificationResult) -> anyhow::Result<ClassificationResult> {
|
||||
let url = format!("{}/api/generate", self.endpoint);
|
||||
|
||||
let prompt = format!(
|
||||
"Classify the following note into exactly one type.\n\
|
||||
Valid types: todo, reminder, idea, note, question.\n\
|
||||
Note: \"{}\"\n\
|
||||
Respond with JSON only, using this exact format: \
|
||||
{{\"type\": \"TYPENAME\", \"body\": \"cleaned text\", \"confidence\": 0.0}}",
|
||||
text
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"format": "json",
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let response = ureq::post(&url)
|
||||
.set("Content-Type", "application/json")
|
||||
.send_json(payload)
|
||||
.map_err(|e| anyhow::anyhow!("Ollama HTTP error: {}", e))?;
|
||||
|
||||
let ollama_resp: OllamaGenerateResponse = response
|
||||
.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,
|
||||
&ollama_resp.response
|
||||
))?;
|
||||
|
||||
let note_type = classification
|
||||
.note_type
|
||||
.as_deref()
|
||||
.map(NoteType::from_str)
|
||||
.unwrap_or_else(|| fallback.note_type.clone());
|
||||
|
||||
let confidence = classification
|
||||
.confidence
|
||||
.unwrap_or(0.75)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
// Use Tier 1's time/rrule/body as the ground truth; optionally use the LLM's
|
||||
// cleaned body if it provided one and Tier 1 didn't already strip anything.
|
||||
let body = if let Some(llm_body) = classification.body.filter(|b| !b.trim().is_empty()) {
|
||||
if fallback.body == text {
|
||||
// Tier 1 didn't clean the body — accept LLM's version
|
||||
llm_body
|
||||
} else {
|
||||
// Tier 1 already stripped time phrases — keep its result
|
||||
fallback.body.clone()
|
||||
}
|
||||
} else {
|
||||
fallback.body.clone()
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Tier 3: classified {:?} as {:?} (conf={:.2})",
|
||||
text, note_type, confidence
|
||||
);
|
||||
|
||||
Ok(ClassificationResult {
|
||||
note_type,
|
||||
confidence,
|
||||
time: fallback.time,
|
||||
rrule: fallback.rrule.clone(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
221
breadpad-shared/src/calendar.rs
Normal file
221
breadpad-shared/src/calendar.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
use crate::config::CalendarConfig;
|
||||
use crate::types::Note;
|
||||
use anyhow::{Context, Result};
|
||||
use ical::IcalParser;
|
||||
use std::io::BufReader;
|
||||
|
||||
pub struct CalDavClient {
|
||||
config: CalendarConfig,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub struct CalDavEventInfo {
|
||||
pub uid: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
impl CalDavClient {
|
||||
pub fn new(config: CalendarConfig) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
CalDavClient { config, client }
|
||||
}
|
||||
|
||||
pub async fn test_connection(&self) -> Result<()> {
|
||||
let body = r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/></d:prop></d:propfind>"#;
|
||||
let resp = self
|
||||
.client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||
&self.config.url,
|
||||
)
|
||||
.basic_auth(&self.config.username, Some(&self.config.password))
|
||||
.header("Depth", "0")
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("CalDAV PROPFIND request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.as_u16() == 207 {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("CalDAV server returned {}", status);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_event(&self, note: &Note) -> Result<String> {
|
||||
let uid = caldav_uid(note);
|
||||
let ical = build_ical(note, &uid);
|
||||
let url = event_url(&self.config.url, &uid);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.basic_auth(&self.config.username, Some(&self.config.password))
|
||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||
.body(ical)
|
||||
.send()
|
||||
.await
|
||||
.context("CalDAV PUT request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.as_u16() == 201 || status.as_u16() == 204 {
|
||||
Ok(uid)
|
||||
} else {
|
||||
anyhow::bail!("CalDAV PUT returned {}", status);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_event(&self, uid: &str) -> Result<()> {
|
||||
let url = event_url(&self.config.url, uid);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.delete(&url)
|
||||
.basic_auth(&self.config.username, Some(&self.config.password))
|
||||
.send()
|
||||
.await
|
||||
.context("CalDAV DELETE request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.as_u16() == 204 || status.as_u16() == 404 {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("CalDAV DELETE returned {}", status);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_events(&self) -> Result<Vec<CalDavEventInfo>> {
|
||||
let body = r#"<?xml version="1.0"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT"/>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>"#;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"REPORT").unwrap(),
|
||||
&self.config.url,
|
||||
)
|
||||
.basic_auth(&self.config.username, Some(&self.config.password))
|
||||
.header("Depth", "1")
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("CalDAV REPORT request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() && status.as_u16() != 207 {
|
||||
anyhow::bail!("CalDAV REPORT returned {}", status);
|
||||
}
|
||||
|
||||
let xml = resp.text().await.context("failed to read CalDAV REPORT body")?;
|
||||
parse_report_response(&xml)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn caldav_uid(note: &Note) -> String {
|
||||
note.caldav_uid
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}@breadpad", note.id))
|
||||
}
|
||||
|
||||
fn event_url(base: &str, uid: &str) -> String {
|
||||
format!("{}/{}.ics", base.trim_end_matches('/'), uid)
|
||||
}
|
||||
|
||||
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(¬e.body);
|
||||
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
if let Some(rrule) = ¬e.rrule {
|
||||
ical.push_str(rrule.as_str());
|
||||
ical.push_str("\r\n");
|
||||
}
|
||||
|
||||
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
ical
|
||||
}
|
||||
|
||||
fn escape_ical(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace(';', "\\;")
|
||||
.replace(',', "\\,")
|
||||
.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
fn parse_report_response(xml: &str) -> Result<Vec<CalDavEventInfo>> {
|
||||
let mut events = Vec::new();
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(start) = xml[search_from..].find("BEGIN:VCALENDAR") {
|
||||
let abs_start = search_from + start;
|
||||
let tail = &xml[abs_start..];
|
||||
let end = match tail.find("END:VCALENDAR") {
|
||||
Some(e) => abs_start + e + "END:VCALENDAR".len(),
|
||||
None => break,
|
||||
};
|
||||
let ical_block = &xml[abs_start..end];
|
||||
events.extend(parse_ical_block(ical_block));
|
||||
search_from = end;
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
|
||||
let reader = BufReader::new(data.as_bytes());
|
||||
let parser = IcalParser::new(reader);
|
||||
let mut out = Vec::new();
|
||||
|
||||
for item in parser {
|
||||
match item {
|
||||
Ok(cal) => {
|
||||
for event in cal.events {
|
||||
let uid = event
|
||||
.properties
|
||||
.iter()
|
||||
.find(|p| p.name == "UID")
|
||||
.and_then(|p| p.value.clone())
|
||||
.unwrap_or_default();
|
||||
let summary = event
|
||||
.properties
|
||||
.iter()
|
||||
.find(|p| p.name == "SUMMARY")
|
||||
.and_then(|p| p.value.clone())
|
||||
.unwrap_or_default();
|
||||
out.push(CalDavEventInfo { uid, summary });
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("CalDAV: failed to parse VCALENDAR block: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
284
breadpad-shared/src/classifier.rs
Normal file
284
breadpad-shared/src/classifier.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
use crate::ai::OllamaClient;
|
||||
use crate::config::OllamaConfig;
|
||||
use crate::parser::parse_rule_based;
|
||||
use crate::types::{ClassificationResult, NoteType};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Minimum Tier 1 confidence needed to skip Tier 2 entirely.
|
||||
const TIER1_SKIP_THRESHOLD: f32 = 0.82;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ExecutionProvider {
|
||||
Qnn,
|
||||
Vulkan,
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl ExecutionProvider {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ExecutionProvider::Qnn => "QNN (NPU)",
|
||||
ExecutionProvider::Vulkan => "Vulkan",
|
||||
ExecutionProvider::Cpu => "CPU",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Classifier {
|
||||
session: Option<ort::session::Session>,
|
||||
tokenizer: Option<tokenizers::Tokenizer>,
|
||||
pub active_provider: ExecutionProvider,
|
||||
pub model_path: PathBuf,
|
||||
pub default_morning: String,
|
||||
ollama: Option<OllamaConfig>,
|
||||
}
|
||||
|
||||
fn model_dir() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("breadpad")
|
||||
.join("model")
|
||||
}
|
||||
|
||||
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 {
|
||||
let dir = model_dir();
|
||||
let onnx_path = dir.join("classifier.onnx");
|
||||
let tok_path = dir.join("tokenizer.json");
|
||||
|
||||
let (session, active_provider) = if onnx_path.exists() {
|
||||
try_load_session(&onnx_path, ep_pref)
|
||||
} else {
|
||||
tracing::warn!("model not found at {:?}; Tier 2 disabled", onnx_path);
|
||||
(None, ExecutionProvider::Cpu)
|
||||
};
|
||||
|
||||
let tokenizer = if tok_path.exists() && session.is_some() {
|
||||
match tokenizers::Tokenizer::from_file(&tok_path) {
|
||||
Ok(tok) => Some(tok),
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to load tokenizer: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Classifier {
|
||||
session,
|
||||
tokenizer,
|
||||
active_provider,
|
||||
model_path: onnx_path,
|
||||
default_morning: default_morning.to_string(),
|
||||
ollama: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable Tier 3 (Ollama). Only has an effect if `cfg.enabled` is true.
|
||||
pub fn with_ollama(mut self, cfg: OllamaConfig) -> Self {
|
||||
self.ollama = if cfg.enabled { Some(cfg) } else { None };
|
||||
self
|
||||
}
|
||||
|
||||
/// Three-tier classification pipeline:
|
||||
///
|
||||
/// - **Tier 1** (rule-based parser): always runs; handles time/recurrence extraction
|
||||
/// and obvious type signals. If confidence ≥ 0.82, result is returned immediately.
|
||||
/// - **Tier 2** (small ONNX model): runs when Tier 1 is uncertain about the type.
|
||||
/// Responsible for type classification only; Tier 1's time/rrule/body are preserved.
|
||||
/// - **Tier 3** (Ollama LLM): runs when Tier 2 confidence is below the configured
|
||||
/// threshold. Falls back to the Tier 2 result if Ollama is unreachable.
|
||||
pub fn classify(&mut self, text: &str) -> ClassificationResult {
|
||||
// ── Tier 1 ───────────────────────────────────────────────────────────
|
||||
let tier1 = parse_rule_based(text, &self.default_morning);
|
||||
tracing::debug!("Tier 1: {:?} conf={:.2}", tier1.note_type, tier1.confidence);
|
||||
|
||||
if tier1.confidence >= TIER1_SKIP_THRESHOLD {
|
||||
return tier1;
|
||||
}
|
||||
|
||||
// ── Tier 2 ───────────────────────────────────────────────────────────
|
||||
// ONNX model classifies the type only; Tier 1's time/rrule/body are kept.
|
||||
let tier2 = if let (Some(session), Some(tokenizer)) =
|
||||
(&mut self.session, &self.tokenizer)
|
||||
{
|
||||
match run_onnx(session, tokenizer, text) {
|
||||
Ok(r) => {
|
||||
tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence);
|
||||
ClassificationResult {
|
||||
note_type: r.note_type,
|
||||
confidence: r.confidence,
|
||||
time: tier1.time,
|
||||
rrule: tier1.rrule.clone(),
|
||||
body: tier1.body.clone(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Tier 2 inference failed: {}; using Tier 1 result", e);
|
||||
tier1.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tier1.clone()
|
||||
};
|
||||
|
||||
// ── Tier 3 ───────────────────────────────────────────────────────────
|
||||
if let Some(ollama_cfg) = &self.ollama {
|
||||
if tier2.confidence < ollama_cfg.confidence_threshold {
|
||||
tracing::debug!(
|
||||
"Tier 2 confidence {:.2} < threshold {:.2}; trying Tier 3",
|
||||
tier2.confidence,
|
||||
ollama_cfg.confidence_threshold
|
||||
);
|
||||
let client = OllamaClient::new(ollama_cfg);
|
||||
return client.classify(text, &tier2);
|
||||
}
|
||||
}
|
||||
|
||||
tier2
|
||||
}
|
||||
|
||||
pub fn model_available(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// NLI hypotheses paired with their note types. The model scores each as
|
||||
// entailment (label 0) vs not_entailment (label 1); we pick the highest
|
||||
// entailment score across all five passes.
|
||||
const HYPOTHESES: [(&str, &str); 5] = [
|
||||
("This note is a task or action item to complete.", "todo"),
|
||||
("This note is a reminder with a specific time or deadline.", "reminder"),
|
||||
("This note is an idea, suggestion, or creative thought.", "idea"),
|
||||
("This note is a general observation or piece of information.", "note"),
|
||||
("This note is a question that needs an answer.", "question"),
|
||||
];
|
||||
|
||||
fn run_onnx(
|
||||
session: &mut ort::session::Session,
|
||||
tokenizer: &tokenizers::Tokenizer,
|
||||
text: &str,
|
||||
) -> anyhow::Result<ClassificationResult> {
|
||||
const ENTAILMENT_IDX: usize = 0;
|
||||
|
||||
let mut entailment_scores = [0.0f32; 5];
|
||||
|
||||
for (i, (hypothesis, _)) in HYPOTHESES.iter().enumerate() {
|
||||
let encoding = tokenizer
|
||||
.encode((text, *hypothesis), true)
|
||||
.map_err(|e| anyhow::anyhow!("tokenize: {}", e))?;
|
||||
|
||||
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
||||
let mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
|
||||
let len = ids.len();
|
||||
|
||||
let ids_tensor = ort::value::Tensor::<i64>::from_array(
|
||||
(vec![1i64, len as i64], ids)
|
||||
).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
|
||||
let mask_tensor = ort::value::Tensor::<i64>::from_array(
|
||||
(vec![1i64, len as i64], mask)
|
||||
).map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
|
||||
|
||||
let inputs = ort::inputs![
|
||||
"input_ids" => ids_tensor,
|
||||
"attention_mask" => mask_tensor,
|
||||
];
|
||||
let outputs = session
|
||||
.run(inputs)
|
||||
.map_err(|e| anyhow::anyhow!("run: {}", e))?;
|
||||
|
||||
let logits = outputs["logits"]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| anyhow::anyhow!("extract logits: {}", e))?;
|
||||
let (_, logits_slice) = logits;
|
||||
|
||||
entailment_scores[i] = logits_slice
|
||||
.get(ENTAILMENT_IDX)
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
}
|
||||
|
||||
let best_idx = entailment_scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(3);
|
||||
|
||||
let note_type = NoteType::from_str(HYPOTHESES[best_idx].1);
|
||||
let confidence = softmax_single(&entailment_scores, best_idx);
|
||||
|
||||
Ok(ClassificationResult {
|
||||
note_type,
|
||||
confidence,
|
||||
// Time/rrule/body are merged by the caller from Tier 1's result.
|
||||
time: None,
|
||||
rrule: None,
|
||||
body: text.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn softmax_single(logits: &[f32], idx: usize) -> f32 {
|
||||
if logits.is_empty() || idx >= logits.len() {
|
||||
return 0.5;
|
||||
}
|
||||
let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let exps: Vec<f32> = logits.iter().map(|&x| (x - max).exp()).collect();
|
||||
let sum: f32 = exps.iter().sum();
|
||||
exps[idx] / sum
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(None, ExecutionProvider::Cpu)
|
||||
}
|
||||
|
||||
fn build_session(
|
||||
path: &std::path::Path,
|
||||
ep_name: &str,
|
||||
) -> 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)),
|
||||
}
|
||||
}
|
||||
180
breadpad-shared/src/config.rs
Normal file
180
breadpad-shared/src/config.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn default_type_str() -> String { "note".into() }
|
||||
fn default_workspace_tag() -> bool { true }
|
||||
fn default_snooze_options() -> Vec<String> {
|
||||
vec!["15m".into(), "1h".into(), "tomorrow_morning".into()]
|
||||
}
|
||||
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_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_confidence_threshold() -> f32 { 0.6 }
|
||||
fn default_ollama_enabled() -> bool { true }
|
||||
fn default_calendar_enabled() -> bool { false }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_type_str")]
|
||||
pub default_type: String,
|
||||
#[serde(default = "default_workspace_tag")]
|
||||
pub workspace_tag: bool,
|
||||
#[serde(default = "default_snooze_options")]
|
||||
pub snooze_options: Vec<String>,
|
||||
#[serde(default = "default_archive_after_days")]
|
||||
pub archive_after_days: i64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
default_type: default_type_str(),
|
||||
workspace_tag: default_workspace_tag(),
|
||||
snooze_options: default_snooze_options(),
|
||||
archive_after_days: default_archive_after_days(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OllamaConfig {
|
||||
#[serde(default = "default_ollama_endpoint")]
|
||||
pub endpoint: String,
|
||||
#[serde(default = "default_ollama_model")]
|
||||
pub model: String,
|
||||
#[serde(default = "default_ollama_confidence_threshold")]
|
||||
pub confidence_threshold: f32,
|
||||
#[serde(default = "default_ollama_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for OllamaConfig {
|
||||
fn default() -> Self {
|
||||
OllamaConfig {
|
||||
endpoint: default_ollama_endpoint(),
|
||||
model: default_ollama_model(),
|
||||
confidence_threshold: default_ollama_confidence_threshold(),
|
||||
enabled: default_ollama_enabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelConfig {
|
||||
#[serde(default = "default_model_path")]
|
||||
pub path: String,
|
||||
#[serde(default = "default_tokenizer_path")]
|
||||
pub tokenizer: String,
|
||||
#[serde(default = "default_execution_provider")]
|
||||
pub execution_provider: String,
|
||||
#[serde(default)]
|
||||
pub ollama: OllamaConfig,
|
||||
}
|
||||
|
||||
impl Default for ModelConfig {
|
||||
fn default() -> Self {
|
||||
ModelConfig {
|
||||
path: default_model_path(),
|
||||
tokenizer: default_tokenizer_path(),
|
||||
execution_provider: default_execution_provider(),
|
||||
ollama: OllamaConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemindersConfig {
|
||||
#[serde(default = "default_morning_time")]
|
||||
pub default_morning: String,
|
||||
#[serde(default = "default_missed_grace_minutes")]
|
||||
pub missed_grace_minutes: i64,
|
||||
}
|
||||
|
||||
impl Default for RemindersConfig {
|
||||
fn default() -> Self {
|
||||
RemindersConfig {
|
||||
default_morning: default_morning_time(),
|
||||
missed_grace_minutes: default_missed_grace_minutes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalendarConfig {
|
||||
#[serde(default = "default_calendar_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl Default for CalendarConfig {
|
||||
fn default() -> Self {
|
||||
CalendarConfig {
|
||||
enabled: false,
|
||||
url: String::new(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub settings: Settings,
|
||||
#[serde(default)]
|
||||
pub model: ModelConfig,
|
||||
#[serde(default)]
|
||||
pub reminders: RemindersConfig,
|
||||
#[serde(default)]
|
||||
pub calendar: CalendarConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = config_path();
|
||||
if !path.exists() {
|
||||
let cfg = Config::default();
|
||||
cfg.save()?;
|
||||
return Ok(cfg);
|
||||
}
|
||||
let text = fs::read_to_string(&path)?;
|
||||
let cfg: Config = toml::from_str(&text)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let text = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.join("breadpad")
|
||||
.join("breadpad.toml")
|
||||
}
|
||||
|
||||
pub fn style_css_path() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.join("breadpad")
|
||||
.join("style.css")
|
||||
}
|
||||
9
breadpad-shared/src/lib.rs
Normal file
9
breadpad-shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub mod ai;
|
||||
pub mod calendar;
|
||||
pub mod classifier;
|
||||
pub mod config;
|
||||
pub mod parser;
|
||||
pub mod scheduler;
|
||||
pub mod store;
|
||||
pub mod theme;
|
||||
pub mod types;
|
||||
882
breadpad-shared/src/parser.rs
Normal file
882
breadpad-shared/src/parser.rs
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
|
||||
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
struct Patterns {
|
||||
at_time: Regex,
|
||||
in_duration: Regex,
|
||||
in_duration_word: Regex,
|
||||
tomorrow: Regex,
|
||||
next_weekday: Regex,
|
||||
tonight: Regex,
|
||||
every_weekdays: Regex,
|
||||
every_weekday: Regex,
|
||||
every_week: Regex,
|
||||
every_day: Regex,
|
||||
morning_evening: Regex,
|
||||
}
|
||||
|
||||
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(),
|
||||
// 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?)"
|
||||
).unwrap(),
|
||||
tomorrow: Regex::new(r"(?i)\btomorrow(?:\s+morning|\s+evening|\s+afternoon)?").unwrap(),
|
||||
next_weekday: Regex::new(r"(?i)\bnext\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)").unwrap(),
|
||||
// "tonight" or "this evening" — maps to a fixed 21:00 anchor
|
||||
tonight: Regex::new(r"(?i)\b(?:tonight|this\s+evening)\b").unwrap(),
|
||||
// "every weekday [at H:MM|morning|afternoon|evening]" → Mon–Fri RRULE
|
||||
every_weekdays: Regex::new(
|
||||
r"(?i)\bevery\s+weekday(?:\s+(?:at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?|(morning|afternoon|evening)))?"
|
||||
).unwrap(),
|
||||
every_weekday: Regex::new(r"(?i)\bevery\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(),
|
||||
// \bweek\b prevents "weekday" from being matched here
|
||||
every_week: Regex::new(r"(?i)\bevery\s+week\b(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(),
|
||||
every_day: Regex::new(r"(?i)\bevery\s+day(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(),
|
||||
// Strips stray time-of-day words after a time has been extracted.
|
||||
// Handles compound forms ("this morning") before bare words to avoid partial matches.
|
||||
morning_evening: Regex::new(
|
||||
r"(?i)\b(?:this\s+(?:morning|evening|afternoon|night)|tonight|morning|evening|afternoon|night)\b"
|
||||
).unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_time_of_day(hour: &str, min: Option<&str>, ampm: Option<&str>) -> NaiveTime {
|
||||
let mut h: u32 = hour.parse().unwrap_or(9);
|
||||
let m: u32 = min.and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
if let Some(ap) = ampm {
|
||||
if ap.eq_ignore_ascii_case("pm") && h < 12 {
|
||||
h += 12;
|
||||
} else if ap.eq_ignore_ascii_case("am") && h == 12 {
|
||||
h = 0;
|
||||
}
|
||||
}
|
||||
NaiveTime::from_hms_opt(h, m, 0).unwrap_or(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
|
||||
}
|
||||
|
||||
fn weekday_from_str(s: &str) -> Weekday {
|
||||
match s.to_lowercase().as_str() {
|
||||
"monday" => Weekday::Mon,
|
||||
"tuesday" => Weekday::Tue,
|
||||
"wednesday" => Weekday::Wed,
|
||||
"thursday" => Weekday::Thu,
|
||||
"friday" => Weekday::Fri,
|
||||
"saturday" => Weekday::Sat,
|
||||
_ => Weekday::Sun,
|
||||
}
|
||||
}
|
||||
|
||||
fn rrule_weekday(wd: Weekday) -> &'static str {
|
||||
match wd {
|
||||
Weekday::Mon => "MO",
|
||||
Weekday::Tue => "TU",
|
||||
Weekday::Wed => "WE",
|
||||
Weekday::Thu => "TH",
|
||||
Weekday::Fri => "FR",
|
||||
Weekday::Sat => "SA",
|
||||
Weekday::Sun => "SU",
|
||||
}
|
||||
}
|
||||
|
||||
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
||||
let local = Local::now();
|
||||
let days_ahead = (wd.num_days_from_monday() as i64
|
||||
- local.weekday().num_days_from_monday() as i64)
|
||||
.rem_euclid(7);
|
||||
let days_ahead = if days_ahead == 0 {
|
||||
if local.time() < time {
|
||||
0
|
||||
} else {
|
||||
7
|
||||
}
|
||||
} else {
|
||||
days_ahead
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
|
||||
let p = patterns();
|
||||
let morning_time: NaiveTime = default_morning
|
||||
.split(':')
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
.get(..2)
|
||||
.and_then(|parts| {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
NaiveTime::from_hms_opt(h, m, 0)
|
||||
})
|
||||
.unwrap_or(NaiveTime::from_hms_opt(8, 0, 0).unwrap());
|
||||
|
||||
let evening_time = NaiveTime::from_hms_opt(18, 0, 0).unwrap();
|
||||
|
||||
let mut extracted_time: Option<DateTime<Utc>> = None;
|
||||
let mut rrule: Option<RecurrenceRule> = None;
|
||||
let mut cleaned = text.to_string();
|
||||
|
||||
// Recurrence: every day
|
||||
if let Some(m) = p.every_day.find(text) {
|
||||
let caps = p.every_day.captures(text).unwrap();
|
||||
let t = if let (Some(h), mp, ap) = (caps.get(1), caps.get(2), caps.get(3)) {
|
||||
parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str()))
|
||||
} else {
|
||||
morning_time
|
||||
};
|
||||
rrule = Some(RecurrenceRule::new(format!(
|
||||
"RRULE:FREQ=DAILY;BYHOUR={};BYMINUTE={};BYSECOND=0",
|
||||
t.hour(),
|
||||
t.minute()
|
||||
)));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
// Recurrence: every week
|
||||
else if let Some(m) = p.every_week.find(text) {
|
||||
let caps = p.every_week.captures(text).unwrap();
|
||||
let t = if let (Some(h), mp, ap) = (caps.get(1), caps.get(2), caps.get(3)) {
|
||||
parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str()))
|
||||
} else {
|
||||
morning_time
|
||||
};
|
||||
let now = Local::now();
|
||||
let wd = rrule_weekday(now.weekday());
|
||||
rrule = Some(RecurrenceRule::new(format!(
|
||||
"RRULE:FREQ=WEEKLY;BYDAY={};BYHOUR={};BYMINUTE={};BYSECOND=0",
|
||||
wd,
|
||||
t.hour(),
|
||||
t.minute()
|
||||
)));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
// Recurrence: every weekday (Mon–Fri)
|
||||
else if let Some(caps) = p.every_weekdays.captures(text) {
|
||||
let t = if let Some(h) = caps.get(1) {
|
||||
parse_time_of_day(h.as_str(), caps.get(2).map(|x| x.as_str()), caps.get(3).map(|x| x.as_str()))
|
||||
} else {
|
||||
match caps.get(4).map(|x| x.as_str().to_lowercase()).as_deref() {
|
||||
Some("afternoon") => NaiveTime::from_hms_opt(14, 0, 0).unwrap(),
|
||||
Some("evening") => evening_time,
|
||||
_ => morning_time,
|
||||
}
|
||||
};
|
||||
rrule = Some(RecurrenceRule::new(format!(
|
||||
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR={};BYMINUTE={};BYSECOND=0",
|
||||
t.hour(),
|
||||
t.minute()
|
||||
)));
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
// Recurrence: every <weekday>
|
||||
else if let Some(caps) = p.every_weekday.captures(text) {
|
||||
let wd_str = caps.get(1).unwrap().as_str();
|
||||
let wd = weekday_from_str(wd_str);
|
||||
let t = if let (Some(h), mp, ap) = (caps.get(2), caps.get(3), caps.get(4)) {
|
||||
parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str()))
|
||||
} else {
|
||||
morning_time
|
||||
};
|
||||
rrule = Some(RecurrenceRule::new(format!(
|
||||
"RRULE:FREQ=WEEKLY;BYDAY={};BYHOUR={};BYMINUTE={};BYSECOND=0",
|
||||
rrule_weekday(wd),
|
||||
t.hour(),
|
||||
t.minute()
|
||||
)));
|
||||
extracted_time = Some(next_occurrence_of_weekday(wd, t));
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
|
||||
// One-off: at <time>
|
||||
if extracted_time.is_none() {
|
||||
if let Some(caps) = p.at_time.captures(text) {
|
||||
let t = parse_time_of_day(
|
||||
caps.get(1).unwrap().as_str(),
|
||||
caps.get(2).map(|x| x.as_str()),
|
||||
caps.get(3).map(|x| x.as_str()),
|
||||
);
|
||||
let local = Local::now();
|
||||
let naive = if local.time() < t {
|
||||
local.date_naive().and_time(t)
|
||||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(t)
|
||||
};
|
||||
extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: in <n> minutes/hours/days
|
||||
else if let Some(caps) = p.in_duration.captures(text) {
|
||||
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() {
|
||||
"minute" => Duration::minutes(n),
|
||||
"hour" => Duration::hours(n),
|
||||
"day" => Duration::days(n),
|
||||
_ => Duration::minutes(n),
|
||||
};
|
||||
extracted_time = Some(Utc::now() + delta);
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: word-form durations — "in an hour", "in a couple of hours", "in half an hour"
|
||||
else if let Some(m) = p.in_duration_word.find(text) {
|
||||
let phrase = m.as_str().to_lowercase();
|
||||
let delta = if phrase.contains("half") {
|
||||
Duration::minutes(30)
|
||||
} else if phrase.contains("couple") {
|
||||
if phrase.contains("hour") { Duration::hours(2) } else { Duration::minutes(2) }
|
||||
} else if phrase.contains("few") {
|
||||
Duration::hours(3)
|
||||
} else if phrase.contains("hour") {
|
||||
Duration::hours(1)
|
||||
} else {
|
||||
Duration::minutes(1)
|
||||
};
|
||||
extracted_time = Some(Utc::now() + delta);
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: tomorrow [morning/evening]
|
||||
else if let Some(m) = p.tomorrow.find(text) {
|
||||
let lower = m.as_str().to_lowercase();
|
||||
let t = if lower.contains("evening") || lower.contains("afternoon") {
|
||||
evening_time
|
||||
} else {
|
||||
morning_time
|
||||
};
|
||||
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));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: next <weekday>
|
||||
else if let Some(caps) = p.next_weekday.captures(text) {
|
||||
let wd = weekday_from_str(caps.get(1).unwrap().as_str());
|
||||
extracted_time = Some(next_occurrence_of_weekday(wd, morning_time));
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: "tonight" / "this evening" — anchors to 21:00
|
||||
else if let Some(m) = p.tonight.find(text) {
|
||||
let local = Local::now();
|
||||
let anchor = NaiveTime::from_hms_opt(21, 0, 0).unwrap();
|
||||
let target = if local.time() < anchor {
|
||||
local.date_naive().and_time(anchor)
|
||||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(anchor)
|
||||
};
|
||||
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Strip stray time-of-day words once any time or recurrence signal was found
|
||||
if extracted_time.is_some() || rrule.is_some() {
|
||||
cleaned = p
|
||||
.morning_evening
|
||||
.replace_all(&cleaned, "")
|
||||
.trim()
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// Infer note type
|
||||
let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some());
|
||||
|
||||
// Trim artifacts
|
||||
cleaned = cleaned
|
||||
.trim_matches(|c: char| c.is_whitespace() || c == ',')
|
||||
.to_string();
|
||||
if cleaned.is_empty() {
|
||||
cleaned = text.to_string();
|
||||
}
|
||||
|
||||
// Calibrated confidence: high when structural signals drove the decision,
|
||||
// low when we fell back to "note" with no positive evidence.
|
||||
let confidence = if rrule.is_some() || extracted_time.is_some() {
|
||||
0.95 // time/recurrence extraction is deterministic
|
||||
} else {
|
||||
match ¬e_type {
|
||||
NoteType::Todo | NoteType::Question => 0.88, // strong lexical anchors
|
||||
NoteType::Idea => 0.84,
|
||||
NoteType::Reminder => 0.95, // shouldn't reach here without time
|
||||
NoteType::Note => 0.45, // no signal — Tier 2 should weigh in
|
||||
_ => 0.60,
|
||||
}
|
||||
};
|
||||
|
||||
ClassificationResult {
|
||||
note_type,
|
||||
time: extracted_time,
|
||||
rrule,
|
||||
body: cleaned,
|
||||
confidence,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Datelike, Local, Timelike, Utc};
|
||||
|
||||
fn p(text: &str) -> ClassificationResult {
|
||||
parse_rule_based(text, "08:00")
|
||||
}
|
||||
|
||||
fn p_morning(text: &str, morning: &str) -> ClassificationResult {
|
||||
parse_rule_based(text, morning)
|
||||
}
|
||||
|
||||
// ---- NoteType inference ----
|
||||
|
||||
#[test]
|
||||
fn todo_buy() {
|
||||
assert_eq!(p("buy groceries").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_pick_up() {
|
||||
assert_eq!(p("pick up dry cleaning").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_fix() {
|
||||
assert_eq!(p("fix the broken test").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_check() {
|
||||
assert_eq!(p("check the deploy status").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_call() {
|
||||
assert_eq!(p("call the dentist").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_finish() {
|
||||
assert_eq!(p("finish the PR description").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_write() {
|
||||
assert_eq!(p("write release notes").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_update() {
|
||||
assert_eq!(p("update breadman dependencies").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_why() {
|
||||
assert_eq!(p("why does nmcli drop on suspend").note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_how() {
|
||||
assert_eq!(p("how do I configure zbus async").note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_what_prefix() {
|
||||
assert_eq!(p("what is the difference between Arc and Rc").note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_ends_with_mark() {
|
||||
assert_eq!(p("is this thread safe?").note_type, NoteType::Question);
|
||||
assert_eq!(p("does GTK4 run on Wayland?").note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_what_if() {
|
||||
assert_eq!(p("what if breadman had a calendar view").note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_prefix() {
|
||||
assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_maybe() {
|
||||
assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_could() {
|
||||
assert_eq!(p("the sidebar could show counts per type").note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_generic_observation() {
|
||||
assert_eq!(p("meeting went well").note_type, NoteType::Note);
|
||||
assert_eq!(p("the new keyboard feels great").note_type, NoteType::Note);
|
||||
}
|
||||
|
||||
// ---- Time extraction: at <time> ----
|
||||
|
||||
#[test]
|
||||
fn at_time_pm_is_reminder_type() {
|
||||
assert_eq!(p("pack bag at 7pm").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_time_am_is_reminder_type() {
|
||||
assert_eq!(p("standup at 9am").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_time_pm_correct_hour() {
|
||||
let r = p("dinner reservation at 7pm");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_time_am_correct_hour() {
|
||||
let r = p("meeting at 10am");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_time_noon() {
|
||||
let r = p("lunch at 12pm");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_time_with_minutes() {
|
||||
let r = p("call mum at 6:30pm");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 18);
|
||||
assert_eq!(local.minute(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_time_no_ampm_bare_number() {
|
||||
// "at 14" should parse as 14:00
|
||||
let r = p("check logs at 14");
|
||||
assert!(r.time.is_some());
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_time_is_in_the_future() {
|
||||
let r = p("check servers at 11pm");
|
||||
assert!(r.time.unwrap() > Utc::now());
|
||||
}
|
||||
|
||||
// ---- Time extraction: in <duration> ----
|
||||
|
||||
#[test]
|
||||
fn in_30_minutes_correct_delta() {
|
||||
let before = Utc::now();
|
||||
let r = p("take a break in 30 minutes");
|
||||
let t = r.time.unwrap();
|
||||
let delta = (t - before).num_seconds();
|
||||
assert!(delta >= 29 * 60 && delta <= 31 * 60, "delta was {}s", delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_1_minute() {
|
||||
let before = Utc::now();
|
||||
let r = p("ping in 1 minute");
|
||||
let delta = (r.time.unwrap() - before).num_seconds();
|
||||
assert!(delta >= 55 && delta <= 65, "delta was {}s", delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_2_hours_correct_delta() {
|
||||
let before = Utc::now();
|
||||
let r = p("review PR in 2 hours");
|
||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_3_days_correct_delta() {
|
||||
let before = Utc::now();
|
||||
let r = p("follow up in 3 days");
|
||||
let delta_h = (r.time.unwrap() - before).num_hours();
|
||||
assert!(delta_h >= 71 && delta_h <= 73, "delta was {}h", delta_h);
|
||||
}
|
||||
|
||||
// ---- Time extraction: tomorrow ----
|
||||
|
||||
#[test]
|
||||
fn tomorrow_morning_correct_date_and_hour() {
|
||||
let r = p("sync tomorrow morning");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
let expected = (Local::now() + Duration::days(1)).date_naive();
|
||||
assert_eq!(local.date_naive(), expected);
|
||||
assert_eq!(local.hour(), 8);
|
||||
assert_eq!(local.minute(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tomorrow_morning_respects_custom_morning_time() {
|
||||
let r = p_morning("standup tomorrow morning", "09:30");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 9);
|
||||
assert_eq!(local.minute(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tomorrow_evening_hour() {
|
||||
let r = p("call family tomorrow evening");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
let expected = (Local::now() + Duration::days(1)).date_naive();
|
||||
assert_eq!(local.date_naive(), expected);
|
||||
assert_eq!(local.hour(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tomorrow_alone_uses_morning_time() {
|
||||
let r = p("dentist appointment tomorrow");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tomorrow_type_is_reminder() {
|
||||
assert_eq!(p("gym tomorrow morning").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
// ---- Time extraction: next <weekday> ----
|
||||
|
||||
#[test]
|
||||
fn next_monday_is_monday() {
|
||||
let r = p("dentist next monday");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Mon);
|
||||
assert!(r.time.unwrap() > Utc::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_friday_is_friday() {
|
||||
let r = p("team lunch next friday");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Fri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_weekday_at_morning_hour() {
|
||||
let r = p("meeting next wednesday");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 8);
|
||||
}
|
||||
|
||||
// ---- Recurrence rules ----
|
||||
|
||||
#[test]
|
||||
fn every_day_daily_rrule() {
|
||||
let r = p("take medication every day");
|
||||
let rule = r.rrule.expect("rrule should be set");
|
||||
assert!(rule.as_str().contains("FREQ=DAILY"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_day_type_is_reminder() {
|
||||
assert_eq!(p("take vitamin every day").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_day_at_time_byhour() {
|
||||
let r = p("stand every day at 7am");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("BYHOUR=7"), "rule: {}", rule.as_str());
|
||||
assert!(rule.as_str().contains("BYMINUTE=0"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_monday_weekly_with_byday() {
|
||||
let r = p("standup every monday");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("FREQ=WEEKLY"), "rule: {}", rule.as_str());
|
||||
assert!(rule.as_str().contains("BYDAY=MO"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_friday_at_time_rrule() {
|
||||
let r = p("team lunch every friday at 1pm");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("BYDAY=FR"), "rule: {}", rule.as_str());
|
||||
assert!(rule.as_str().contains("BYHOUR=13"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sunday_evening_rrule() {
|
||||
let r = p("family call every sunday at 7pm");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("BYDAY=SU"), "rule: {}", rule.as_str());
|
||||
assert!(rule.as_str().contains("BYHOUR=19"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_wednesday_sets_first_time() {
|
||||
let r = p("review PRs every wednesday at 10am");
|
||||
assert!(r.time.is_some(), "recurrence should set initial time");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Wed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_week_at_time_rrule() {
|
||||
let r = p("retro every week at 4pm");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("FREQ=WEEKLY"), "rule: {}", rule.as_str());
|
||||
assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
// ---- Body cleaning ----
|
||||
|
||||
#[test]
|
||||
fn at_time_removed_from_body() {
|
||||
let r = p("pack calculator in bag at 7pm");
|
||||
assert!(!r.body.to_lowercase().contains("at 7pm"), "body: {:?}", r.body);
|
||||
assert!(r.body.contains("pack calculator"), "body: {:?}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_duration_removed_from_body() {
|
||||
let r = p("call dentist in 30 minutes");
|
||||
assert!(!r.body.contains("in 30 minutes"), "body: {:?}", r.body);
|
||||
assert!(r.body.contains("call dentist"), "body: {:?}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_day_removed_from_body() {
|
||||
let r = p("take vitamin every day at 8am");
|
||||
assert!(!r.body.to_lowercase().contains("every day"), "body: {:?}", r.body);
|
||||
assert!(r.body.contains("take vitamin"), "body: {:?}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_never_empty_after_stripping() {
|
||||
for text in &["tomorrow morning", "every day at 8am", "at 9pm", "in 5 minutes"] {
|
||||
let r = p(text);
|
||||
assert!(!r.body.is_empty(), "body was empty for '{}'", text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_body_unchanged() {
|
||||
let r = p("meeting went well, follow up needed");
|
||||
assert_eq!(r.body, "meeting went well, follow up needed");
|
||||
}
|
||||
|
||||
// ---- Edge cases ----
|
||||
|
||||
#[test]
|
||||
fn empty_string_does_not_panic() {
|
||||
let r = p("");
|
||||
assert!(!r.body.is_empty() || r.body.is_empty()); // just don't panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_does_not_panic() {
|
||||
let _ = p(" ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_is_in_range() {
|
||||
for text in &["buy milk", "why?", "idea: tabs", "meeting done"] {
|
||||
let r = p(text);
|
||||
assert!(r.confidence >= 0.0 && r.confidence <= 1.0, "confidence {} for '{}'", r.confidence, text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_with_at_store_not_parsed_as_time() {
|
||||
// "at the store" should NOT parse as a time expression since there is no digit after "at"
|
||||
let r = p("buy milk at the store");
|
||||
// Should still be a Todo (no time extracted)
|
||||
assert_eq!(r.note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
// ---- Word-form durations (in_duration_word) ----
|
||||
|
||||
#[test]
|
||||
fn in_an_hour_sets_time() {
|
||||
let before = Utc::now();
|
||||
let r = p("check on the server in an hour");
|
||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||
assert!(delta_min >= 59 && delta_min <= 61, "delta was {}min", delta_min);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_an_hour_type_is_reminder() {
|
||||
assert_eq!(p("check on the deployment in an hour").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_an_hour_stripped_from_body() {
|
||||
let r = p("check on the deployment in an hour");
|
||||
assert!(!r.body.to_lowercase().contains("in an hour"), "body: {:?}", r.body);
|
||||
assert!(r.body.contains("check on the deployment"), "body: {:?}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_a_couple_of_hours_sets_two_hours() {
|
||||
let before = Utc::now();
|
||||
let r = p("in a couple of hours remind me to check the oven");
|
||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_a_couple_of_hours_is_reminder() {
|
||||
assert_eq!(p("in a couple of hours remind me to check the oven").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_a_few_hours_sets_three_hours() {
|
||||
let before = Utc::now();
|
||||
let r = p("in a few hours we need to submit this");
|
||||
let delta_h = (r.time.unwrap() - before).num_hours();
|
||||
assert_eq!(delta_h, 3, "expected 3h, got {}", delta_h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_half_an_hour_sets_thirty_minutes() {
|
||||
let before = Utc::now();
|
||||
let r = p("in half an hour submit the report");
|
||||
let delta_min = (r.time.unwrap() - before).num_minutes();
|
||||
assert!(delta_min >= 29 && delta_min <= 31, "delta was {}min", delta_min);
|
||||
}
|
||||
|
||||
// ---- Tonight / this evening ----
|
||||
|
||||
#[test]
|
||||
fn tonight_type_is_reminder() {
|
||||
assert_eq!(p("tonight watch the football").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonight_anchors_to_21h() {
|
||||
let r = p("tonight put the bins out");
|
||||
let local: chrono::DateTime<Local> = r.time.unwrap().into();
|
||||
assert_eq!(local.hour(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonight_stripped_from_body() {
|
||||
let r = p("tonight put the bins out");
|
||||
assert!(!r.body.to_lowercase().contains("tonight"), "body: {:?}", r.body);
|
||||
assert!(r.body.contains("put the bins out"), "body: {:?}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_evening_type_is_reminder() {
|
||||
assert_eq!(p("this evening water the plants").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_evening_stripped_from_body() {
|
||||
let r = p("this evening water the plants");
|
||||
assert!(!r.body.to_lowercase().contains("this evening"), "body: {:?}", r.body);
|
||||
assert!(r.body.contains("water the plants"), "body: {:?}", r.body);
|
||||
}
|
||||
|
||||
// ---- Every weekday (Mon–Fri) ----
|
||||
|
||||
#[test]
|
||||
fn every_weekday_is_not_matched_by_every_week() {
|
||||
let r = p("every weekday morning check email");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("BYDAY=MO,TU,WE,TH,FR"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_weekday_type_is_reminder() {
|
||||
assert_eq!(p("every weekday morning check email").note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_weekday_morning_uses_morning_time() {
|
||||
let r = p("every weekday morning standup");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("BYHOUR=8"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_weekday_at_time_uses_explicit_hour() {
|
||||
let r = p("every weekday at 9am standup");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("BYDAY=MO,TU,WE,TH,FR"), "rule: {}", rule.as_str());
|
||||
assert!(rule.as_str().contains("BYHOUR=9"), "rule: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_weekday_body_cleaned() {
|
||||
let r = p("every weekday morning check email");
|
||||
assert!(!r.body.to_lowercase().contains("every weekday"), "body: {:?}", r.body);
|
||||
assert!(!r.body.to_lowercase().contains("morning"), "body: {:?}", r.body);
|
||||
assert!(r.body.contains("check email"), "body: {:?}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_week_not_confused_by_weekday() {
|
||||
// "every weekday" should NOT produce a BYDAY equal to the current weekday
|
||||
let r = p("every weekday morning check email");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(!rule.as_str().contains("BYDAY=MO;"), "every_week matched weekday: {}", rule.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_week_still_works_after_fix() {
|
||||
let r = p("retro every week at 4pm");
|
||||
let rule = r.rrule.unwrap();
|
||||
assert!(rule.as_str().contains("FREQ=WEEKLY"), "rule: {}", rule.as_str());
|
||||
assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
||||
let lower = text.to_lowercase();
|
||||
if has_rrule || has_time {
|
||||
return NoteType::Reminder;
|
||||
}
|
||||
if lower.contains("buy ")
|
||||
|| lower.contains("pick up")
|
||||
|| lower.contains("clean ")
|
||||
|| lower.starts_with("call ")
|
||||
|| lower.starts_with("email ")
|
||||
|| lower.starts_with("fix ")
|
||||
|| lower.starts_with("check ")
|
||||
|| lower.starts_with("finish ")
|
||||
|| lower.starts_with("write ")
|
||||
|| lower.starts_with("update ")
|
||||
{
|
||||
return NoteType::Todo;
|
||||
}
|
||||
if lower.starts_with("what if ")
|
||||
|| lower.starts_with("idea:")
|
||||
|| lower.contains("could ")
|
||||
|| lower.contains("maybe ")
|
||||
|| lower.contains("should we ")
|
||||
{
|
||||
return NoteType::Idea;
|
||||
}
|
||||
if lower.starts_with("why ")
|
||||
|| lower.starts_with("how ")
|
||||
|| lower.starts_with("what ")
|
||||
|| lower.ends_with('?')
|
||||
{
|
||||
return NoteType::Question;
|
||||
}
|
||||
NoteType::Note
|
||||
}
|
||||
369
breadpad-shared/src/scheduler.rs
Normal file
369
breadpad-shared/src/scheduler.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
use crate::types::Note;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
|
||||
use std::process::Command;
|
||||
|
||||
pub struct Scheduler;
|
||||
|
||||
impl Scheduler {
|
||||
pub fn schedule(note: &Note) -> Result<()> {
|
||||
let fire_time = note.effective_time().context("note has no scheduled time")?;
|
||||
create_timer(¬e.id, fire_time)
|
||||
}
|
||||
|
||||
pub fn cancel(note_id: &str) -> Result<()> {
|
||||
let timer_name = timer_unit_name(note_id);
|
||||
let service_name = service_unit_name(note_id);
|
||||
stop_unit(&timer_name)?;
|
||||
disable_unit(&timer_name)?;
|
||||
stop_unit(&service_name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn snooze(note: &mut Note, snooze_until: DateTime<Utc>) -> Result<()> {
|
||||
Self::cancel(¬e.id).ok();
|
||||
note.snoozed_until = Some(snooze_until);
|
||||
create_timer(¬e.id, snooze_until)
|
||||
}
|
||||
|
||||
pub fn fire(note: &Note, missed_grace_minutes: i64) -> bool {
|
||||
let now = Utc::now();
|
||||
if let Some(t) = note.effective_time() {
|
||||
let diff = now.signed_duration_since(t);
|
||||
if diff > Duration::minutes(missed_grace_minutes) {
|
||||
tracing::info!("reminder {} missed ({}m ago), skipping", note.id, diff.num_minutes());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn next_recurrence(note: &Note, default_morning: &str) -> Option<DateTime<Utc>> {
|
||||
let rrule = note.rrule.as_ref()?;
|
||||
parse_next_from_rrule(rrule.as_str(), default_morning)
|
||||
}
|
||||
}
|
||||
|
||||
fn timer_unit_name(id: &str) -> String {
|
||||
format!("breadpad-reminder-{}.timer", id)
|
||||
}
|
||||
|
||||
fn service_unit_name(id: &str) -> String {
|
||||
format!("breadpad-reminder-{}.service", id)
|
||||
}
|
||||
|
||||
fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
|
||||
// Convert to local time for systemd OnCalendar
|
||||
let local: chrono::DateTime<Local> = fire_time.with_timezone(&Local);
|
||||
let on_calendar = local.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let timer_name = timer_unit_name(id);
|
||||
|
||||
// Use systemd-run to create both service + timer as a transient unit
|
||||
let status = Command::new("systemd-run")
|
||||
.arg("--user")
|
||||
.arg("--unit")
|
||||
.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("fire")
|
||||
.arg(id)
|
||||
.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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_unit(unit: &str) -> Result<()> {
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "stop", unit])
|
||||
.status()
|
||||
.context("systemctl stop")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disable_unit(unit: &str) -> Result<()> {
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "disable", "--now", unit])
|
||||
.status()
|
||||
.context("systemctl disable")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> Option<DateTime<Utc>> {
|
||||
// (see tests module below for coverage)
|
||||
// Parse RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0 etc.
|
||||
// We extract FREQ, BYDAY, BYHOUR, BYMINUTE to compute next occurrence.
|
||||
if rrule_str.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: std::collections::HashMap<&str, &str> = rrule_str
|
||||
.trim_start_matches("RRULE:")
|
||||
.split(';')
|
||||
.filter_map(|part| {
|
||||
let mut kv = part.splitn(2, '=');
|
||||
Some((kv.next()?, kv.next()?))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let freq = parts.get("FREQ")?;
|
||||
let freq = *freq;
|
||||
|
||||
let (default_h, default_m): (u32, u32) = {
|
||||
let mut it = default_morning.splitn(2, ':');
|
||||
let h = it.next().and_then(|s| s.parse().ok()).unwrap_or(8);
|
||||
let m = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
(h, m)
|
||||
};
|
||||
let hour: u32 = parts.get("BYHOUR").and_then(|v| v.parse().ok()).unwrap_or(default_h);
|
||||
let minute: u32 = parts.get("BYMINUTE").and_then(|v| v.parse().ok()).unwrap_or(default_m);
|
||||
|
||||
let now = Local::now();
|
||||
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
|
||||
|
||||
let next = match freq {
|
||||
"DAILY" => {
|
||||
let today = now.date_naive().and_time(fire_time);
|
||||
if now.naive_local() < today {
|
||||
today.and_local_timezone(Local).unwrap()
|
||||
} else {
|
||||
(now.date_naive() + chrono::Duration::days(1))
|
||||
.and_time(fire_time)
|
||||
.and_local_timezone(Local)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
"WEEKLY" => {
|
||||
use chrono::Datelike;
|
||||
let byday = parts.get("BYDAY").unwrap_or(&"MO");
|
||||
let target_wd = match *byday {
|
||||
"MO" => chrono::Weekday::Mon,
|
||||
"TU" => chrono::Weekday::Tue,
|
||||
"WE" => chrono::Weekday::Wed,
|
||||
"TH" => chrono::Weekday::Thu,
|
||||
"FR" => chrono::Weekday::Fri,
|
||||
"SA" => chrono::Weekday::Sat,
|
||||
_ => chrono::Weekday::Sun,
|
||||
};
|
||||
let days_ahead = (target_wd.num_days_from_monday() as i64
|
||||
- now.weekday().num_days_from_monday() as i64)
|
||||
.rem_euclid(7);
|
||||
let days_ahead = if days_ahead == 0 {
|
||||
if now.time() < fire_time {
|
||||
0
|
||||
} else {
|
||||
7
|
||||
}
|
||||
} else {
|
||||
days_ahead
|
||||
};
|
||||
let target_date =
|
||||
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
||||
target_date.and_local_timezone(Local).unwrap()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(next.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{Note, NoteType, RecurrenceRule};
|
||||
use chrono::{Datelike, Local, Timelike, Utc};
|
||||
|
||||
fn reminder(id: &str) -> Note {
|
||||
let mut n = Note::new("test reminder".into(), NoteType::Reminder, None);
|
||||
// Override auto-generated id for readable test names
|
||||
n.id = id.to_string();
|
||||
n
|
||||
}
|
||||
|
||||
// ---- Scheduler::fire ----
|
||||
|
||||
#[test]
|
||||
fn fire_note_without_time_always_fires() {
|
||||
let note = reminder("no-time");
|
||||
// effective_time() is None → fire returns true (no time = no missed check)
|
||||
assert!(Scheduler::fire(¬e, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_future_reminder_fires() {
|
||||
let mut note = reminder("future");
|
||||
note.time = Some(Utc::now() + Duration::minutes(10));
|
||||
assert!(Scheduler::fire(¬e, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_recent_past_reminder_fires() {
|
||||
let mut note = reminder("recent");
|
||||
note.time = Some(Utc::now() - Duration::minutes(5));
|
||||
// 5 min ago, grace = 60 min → should fire
|
||||
assert!(Scheduler::fire(¬e, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_exactly_at_grace_boundary_fires() {
|
||||
let mut note = reminder("boundary");
|
||||
// Use 59 min (well inside the 60-min grace) to avoid a race with wall time
|
||||
note.time = Some(Utc::now() - Duration::minutes(59));
|
||||
assert!(Scheduler::fire(¬e, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_missed_reminder_beyond_grace_skips() {
|
||||
let mut note = reminder("missed");
|
||||
note.time = Some(Utc::now() - Duration::minutes(90));
|
||||
// 90 min ago, grace = 60 → should NOT fire
|
||||
assert!(!Scheduler::fire(¬e, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_uses_snoozed_until_if_set() {
|
||||
let mut note = reminder("snoozed");
|
||||
note.time = Some(Utc::now() - Duration::hours(5)); // original would be missed
|
||||
note.snoozed_until = Some(Utc::now() - Duration::minutes(5)); // snooze is recent
|
||||
// effective_time = snoozed_until (5 min ago), grace = 60 → fires
|
||||
assert!(Scheduler::fire(¬e, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_zero_grace_only_fires_future() {
|
||||
let mut future = reminder("zero-future");
|
||||
future.time = Some(Utc::now() + Duration::seconds(1));
|
||||
assert!(Scheduler::fire(&future, 0));
|
||||
|
||||
let mut past = reminder("zero-past");
|
||||
past.time = Some(Utc::now() - Duration::seconds(1));
|
||||
assert!(!Scheduler::fire(&past, 0));
|
||||
}
|
||||
|
||||
// ---- Scheduler::next_recurrence ----
|
||||
|
||||
#[test]
|
||||
fn next_recurrence_none_without_rrule() {
|
||||
let note = reminder("no-rrule");
|
||||
assert!(Scheduler::next_recurrence(¬e, "08:00").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_recurrence_daily_in_future() {
|
||||
let mut note = reminder("daily");
|
||||
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=DAILY;BYHOUR=8;BYMINUTE=0;BYSECOND=0"));
|
||||
let t = Scheduler::next_recurrence(¬e, "08:00").unwrap();
|
||||
assert!(t >= Utc::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_recurrence_weekly_is_correct_weekday() {
|
||||
let mut note = reminder("weekly-mon");
|
||||
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
|
||||
let t = Scheduler::next_recurrence(¬e, "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Mon);
|
||||
}
|
||||
|
||||
// ---- parse_next_from_rrule ----
|
||||
|
||||
#[test]
|
||||
fn daily_rrule_next_is_in_future() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=0;BYSECOND=0", "08:00");
|
||||
assert!(t.is_some());
|
||||
assert!(t.unwrap() >= Utc::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_rrule_correct_hour() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.hour(), 14);
|
||||
assert_eq!(local.minute(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_rrule_defaults_hour_from_morning() {
|
||||
// No BYHOUR in rrule — should fall back to default_morning
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "07:15").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.hour(), 7);
|
||||
assert_eq!(local.minute(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_monday_is_monday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Mon);
|
||||
assert!(t >= Utc::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_friday_is_friday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=FR;BYHOUR=12;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Fri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_wednesday_correct_hour() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=WE;BYHOUR=15;BYMINUTE=45;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.hour(), 15);
|
||||
assert_eq!(local.minute(), 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_saturday_is_saturday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SA;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Sat);
|
||||
}
|
||||
|
||||
#[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();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_freq_returns_none() {
|
||||
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rrule_string_returns_none() {
|
||||
assert!(parse_next_from_rrule("", "08:00").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rrule_without_rrule_prefix_still_parses() {
|
||||
// trim_start_matches("RRULE:") handles the prefix; without it the key would be "RRULE:FREQ" which won't match
|
||||
// just verify we don't panic
|
||||
let _ = parse_next_from_rrule("FREQ=DAILY;BYHOUR=8;BYMINUTE=0", "08:00");
|
||||
}
|
||||
|
||||
// ---- unit name helpers ----
|
||||
|
||||
#[test]
|
||||
fn timer_unit_name_format() {
|
||||
assert_eq!(timer_unit_name("abc123"), "breadpad-reminder-abc123.timer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_unit_name_format() {
|
||||
assert_eq!(service_unit_name("abc123"), "breadpad-reminder-abc123.service");
|
||||
}
|
||||
}
|
||||
204
breadpad-shared/src/store.rs
Normal file
204
breadpad-shared/src/store.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
use crate::calendar::{caldav_uid, CalDavClient};
|
||||
use crate::config::CalendarConfig;
|
||||
use crate::types::Note;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Store {
|
||||
notes_path: PathBuf,
|
||||
archive_path: PathBuf,
|
||||
calendar: Option<CalendarConfig>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new() -> Result<Self> {
|
||||
let data_dir = dirs::data_local_dir()
|
||||
.context("no XDG data dir")?
|
||||
.join("breadpad");
|
||||
Self::from_dir(&data_dir)
|
||||
}
|
||||
|
||||
pub fn from_dir(dir: &Path) -> Result<Self> {
|
||||
fs::create_dir_all(dir)?;
|
||||
Ok(Store {
|
||||
notes_path: dir.join("notes.jsonl"),
|
||||
archive_path: dir.join("archive.jsonl"),
|
||||
calendar: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_calendar(mut self, cfg: CalendarConfig) -> Self {
|
||||
self.calendar = Some(cfg);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn load_all(&self) -> Result<Vec<Note>> {
|
||||
self.load_from(&self.notes_path)
|
||||
}
|
||||
|
||||
pub fn load_archive(&self) -> Result<Vec<Note>> {
|
||||
self.load_from(&self.archive_path)
|
||||
}
|
||||
|
||||
fn load_from(&self, path: &Path) -> Result<Vec<Note>> {
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file = fs::File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut notes = Vec::new();
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<Note>(trimmed) {
|
||||
Ok(note) => notes.push(note),
|
||||
Err(e) => tracing::warn!("skipping malformed note at line {}: {}", i + 1, e),
|
||||
}
|
||||
}
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
pub fn save_note(&self, note: &Note) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.notes_path)?;
|
||||
let line = serde_json::to_string(note)?;
|
||||
writeln!(file, "{}", line)?;
|
||||
|
||||
if let Some(cal_cfg) = &self.calendar {
|
||||
if cal_cfg.enabled && (note.time.is_some() || note.rrule.is_some()) {
|
||||
spawn_caldav_push(note.clone(), cal_cfg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_note(&self, updated: &Note) -> Result<()> {
|
||||
self.rewrite_notes(|note| {
|
||||
if note.id == updated.id {
|
||||
updated.clone()
|
||||
} else {
|
||||
note
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_note(&self, id: &str) -> Result<()> {
|
||||
let all = self.load_all()?;
|
||||
let (to_delete, keep): (Vec<Note>, Vec<Note>) = all.into_iter().partition(|n| n.id == id);
|
||||
self.write_all(&self.notes_path, &keep)?;
|
||||
|
||||
if let Some(cal_cfg) = &self.calendar {
|
||||
if cal_cfg.enabled {
|
||||
if let Some(note) = to_delete.into_iter().next() {
|
||||
spawn_caldav_delete(caldav_uid(¬e), cal_cfg.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewrite_notes<F>(&self, mut f: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(Note) -> Note,
|
||||
{
|
||||
let notes: Vec<Note> = self.load_all()?.into_iter().map(|n| f(n)).collect();
|
||||
self.write_all(&self.notes_path, ¬es)
|
||||
}
|
||||
|
||||
fn write_all(&self, path: &Path, notes: &[Note]) -> Result<()> {
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
{
|
||||
let mut file = fs::File::create(&tmp_path)?;
|
||||
for note in notes {
|
||||
let line = serde_json::to_string(note)?;
|
||||
writeln!(file, "{}", line)?;
|
||||
}
|
||||
file.flush()?;
|
||||
}
|
||||
fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rotate_archive(&self, archive_after_days: i64) -> Result<usize> {
|
||||
let cutoff = Utc::now() - Duration::days(archive_after_days);
|
||||
let notes = self.load_all()?;
|
||||
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
|
||||
.into_iter()
|
||||
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff));
|
||||
|
||||
if to_archive.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let count = to_archive.len();
|
||||
let mut archive_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.archive_path)?;
|
||||
for note in &to_archive {
|
||||
writeln!(archive_file, "{}", serde_json::to_string(note)?)?;
|
||||
}
|
||||
|
||||
self.write_all(&self.notes_path, &keep)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<Note>> {
|
||||
Ok(self.load_all()?.into_iter().find(|n| n.id == id))
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_caldav_push(note: Note, cfg: CalendarConfig) {
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::warn!("CalDAV: failed to create runtime: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async {
|
||||
let client = CalDavClient::new(cfg);
|
||||
match client.push_event(¬e).await {
|
||||
Ok(uid) => tracing::info!("CalDAV: pushed note {} as {}", note.id, uid),
|
||||
Err(e) => tracing::warn!("CalDAV: push failed for note {}: {}", note.id, e),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_caldav_delete(uid: String, cfg: CalendarConfig) {
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::warn!("CalDAV: failed to create runtime: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async {
|
||||
let client = CalDavClient::new(cfg);
|
||||
match client.delete_event(&uid).await {
|
||||
Ok(()) => tracing::info!("CalDAV: deleted event {}", uid),
|
||||
Err(e) => tracing::warn!("CalDAV: delete failed for {}: {}", uid, e),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
447
breadpad-shared/src/theme.rs
Normal file
447
breadpad-shared/src/theme.rs
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Palette {
|
||||
pub background: String,
|
||||
pub foreground: String,
|
||||
pub color0: String,
|
||||
pub color1: String,
|
||||
pub color2: String,
|
||||
pub color3: String,
|
||||
pub color4: String,
|
||||
pub color5: String,
|
||||
pub color6: String,
|
||||
pub color7: String,
|
||||
}
|
||||
|
||||
// Catppuccin Mocha fallback
|
||||
impl Default for Palette {
|
||||
fn default() -> Self {
|
||||
Palette {
|
||||
background: "#1e1e2e".into(),
|
||||
foreground: "#cdd6f4".into(),
|
||||
color0: "#45475a".into(),
|
||||
color1: "#f38ba8".into(),
|
||||
color2: "#a6e3a1".into(),
|
||||
color3: "#f9e2af".into(),
|
||||
color4: "#89b4fa".into(),
|
||||
color5: "#f5c2e7".into(),
|
||||
color6: "#94e2d5".into(),
|
||||
color7: "#bac2de".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WalColors {
|
||||
#[serde(default)]
|
||||
colors: HashMap<String, String>,
|
||||
special: Option<WalSpecial>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WalSpecial {
|
||||
background: Option<String>,
|
||||
foreground: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn palette_from_wal_json(json: &str) -> Option<Palette> {
|
||||
let wal: WalColors = serde_json::from_str(json).ok()?;
|
||||
Some(Palette {
|
||||
background: wal.special.as_ref().and_then(|s| s.background.clone()).unwrap_or_else(|| "#1e1e2e".into()),
|
||||
foreground: wal.special.as_ref().and_then(|s| s.foreground.clone()).unwrap_or_else(|| "#cdd6f4".into()),
|
||||
color0: wal.colors.get("color0").cloned().unwrap_or_else(|| "#45475a".into()),
|
||||
color1: wal.colors.get("color1").cloned().unwrap_or_else(|| "#f38ba8".into()),
|
||||
color2: wal.colors.get("color2").cloned().unwrap_or_else(|| "#a6e3a1".into()),
|
||||
color3: wal.colors.get("color3").cloned().unwrap_or_else(|| "#f9e2af".into()),
|
||||
color4: wal.colors.get("color4").cloned().unwrap_or_else(|| "#89b4fa".into()),
|
||||
color5: wal.colors.get("color5").cloned().unwrap_or_else(|| "#f5c2e7".into()),
|
||||
color6: wal.colors.get("color6").cloned().unwrap_or_else(|| "#94e2d5".into()),
|
||||
color7: wal.colors.get("color7").cloned().unwrap_or_else(|| "#bac2de".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_palette() -> Palette {
|
||||
let wal_path = wal_colors_path();
|
||||
if !wal_path.exists() {
|
||||
return Palette::default();
|
||||
}
|
||||
match std::fs::read_to_string(&wal_path)
|
||||
.ok()
|
||||
.and_then(|s| palette_from_wal_json(&s))
|
||||
{
|
||||
Some(wal) => wal,
|
||||
None => Palette::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
|
||||
let mut css = format!(
|
||||
r#"
|
||||
@define-color bg {bg};
|
||||
@define-color fg {fg};
|
||||
@define-color red {c1};
|
||||
@define-color green {c2};
|
||||
@define-color yellow {c3};
|
||||
@define-color blue {c4};
|
||||
@define-color pink {c5};
|
||||
@define-color teal {c6};
|
||||
@define-color overlay {c0};
|
||||
|
||||
window {{
|
||||
background-color: @bg;
|
||||
color: @fg;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
|
||||
.popup-entry {{
|
||||
background: @bg;
|
||||
color: @fg;
|
||||
border: 2px solid @blue;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
caret-color: @fg;
|
||||
}}
|
||||
|
||||
.popup-entry:focus {{
|
||||
outline: none;
|
||||
border-color: @teal;
|
||||
}}
|
||||
|
||||
.type-chip {{
|
||||
background: @overlay;
|
||||
color: @fg;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
margin: 2px;
|
||||
}}
|
||||
|
||||
.type-chip.active {{
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
}}
|
||||
|
||||
.confirm-button {{
|
||||
background: @green;
|
||||
color: @bg;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
.note-card {{
|
||||
background: shade(@bg, 1.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 4px 8px;
|
||||
border-left: 3px solid @blue;
|
||||
}}
|
||||
|
||||
.note-card:hover {{
|
||||
background: shade(@bg, 1.2);
|
||||
}}
|
||||
|
||||
.search-entry {{
|
||||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px;
|
||||
}}
|
||||
"#,
|
||||
bg = palette.background,
|
||||
fg = palette.foreground,
|
||||
c0 = palette.color0,
|
||||
c1 = palette.color1,
|
||||
c2 = palette.color2,
|
||||
c3 = palette.color3,
|
||||
c4 = palette.color4,
|
||||
c5 = palette.color5,
|
||||
c6 = palette.color6,
|
||||
);
|
||||
|
||||
css.push_str(r#"
|
||||
.dim-label {
|
||||
color: alpha(@fg, 0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: shade(@bg, 0.93);
|
||||
}
|
||||
|
||||
.sidebar-row {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-row:hover:not(:selected) {
|
||||
background: shade(@bg, 1.08);
|
||||
}
|
||||
|
||||
.sidebar-row:selected {
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
color: alpha(@fg, 0.4);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 10px 14px 2px 14px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: shade(@bg, 1.3);
|
||||
}
|
||||
|
||||
.done-btn { color: @green; }
|
||||
.done-btn:hover { background: alpha(@green, 0.15); }
|
||||
|
||||
.edit-btn { color: @blue; }
|
||||
.edit-btn:hover { background: alpha(@blue, 0.15); }
|
||||
|
||||
.danger-btn { color: @red; }
|
||||
.danger-btn:hover { background: alpha(@red, 0.15); }
|
||||
|
||||
.note-card-todo { border-left-color: @green; }
|
||||
.note-card-reminder { border-left-color: @yellow; }
|
||||
.note-card-idea { border-left-color: @pink; }
|
||||
.note-card-question { border-left-color: @teal; }
|
||||
.note-card-note { border-left-color: @blue; }
|
||||
|
||||
entry {
|
||||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 6px;
|
||||
caret-color: @fg;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
entry:focus {
|
||||
border-color: @blue;
|
||||
outline: none;
|
||||
}
|
||||
"#);
|
||||
|
||||
if let Some(extra) = user_css {
|
||||
css.push('\n');
|
||||
css.push_str(extra);
|
||||
}
|
||||
|
||||
css
|
||||
}
|
||||
|
||||
fn wal_colors_path() -> PathBuf {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.cache"))
|
||||
.join("wal")
|
||||
.join("colors.json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TOKYO_NIGHT_WAL: &str = r##"{
|
||||
"special": {
|
||||
"background": "#1a1b26",
|
||||
"foreground": "#c0caf5"
|
||||
},
|
||||
"colors": {
|
||||
"color0": "#15161e",
|
||||
"color1": "#f7768e",
|
||||
"color2": "#9ece6a",
|
||||
"color3": "#e0af68",
|
||||
"color4": "#7aa2f7",
|
||||
"color5": "#bb9af7",
|
||||
"color6": "#7dcfff",
|
||||
"color7": "#a9b1d6"
|
||||
}
|
||||
}"##;
|
||||
|
||||
// ---- Default palette (Catppuccin Mocha) ----
|
||||
|
||||
#[test]
|
||||
fn default_background_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().background, "#1e1e2e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_foreground_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().foreground, "#cdd6f4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_red_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().color1, "#f38ba8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_blue_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().color4, "#89b4fa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_teal_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().color6, "#94e2d5");
|
||||
}
|
||||
|
||||
// ---- palette_from_wal_json ----
|
||||
|
||||
#[test]
|
||||
fn wal_json_parses_special_background() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
assert_eq!(p.background, "#1a1b26");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_parses_special_foreground() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
assert_eq!(p.foreground, "#c0caf5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_parses_numbered_colors() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
assert_eq!(p.color0, "#15161e");
|
||||
assert_eq!(p.color1, "#f7768e");
|
||||
assert_eq!(p.color4, "#7aa2f7");
|
||||
assert_eq!(p.color7, "#a9b1d6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_missing_special_falls_back_to_defaults() {
|
||||
let json = r##"{"colors":{"color0":"#000000"}}"##;
|
||||
let p = palette_from_wal_json(json).unwrap();
|
||||
assert_eq!(p.background, "#1e1e2e");
|
||||
assert_eq!(p.foreground, "#cdd6f4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_missing_color_falls_back_to_default() {
|
||||
let json = r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##;
|
||||
let p = palette_from_wal_json(json).unwrap();
|
||||
assert_eq!(p.color4, "#89b4fa"); // default blue
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_wal_json_returns_none() {
|
||||
assert!(palette_from_wal_json("not json").is_none());
|
||||
assert!(palette_from_wal_json("").is_none());
|
||||
assert!(palette_from_wal_json("{}").is_some()); // empty but valid → all defaults
|
||||
}
|
||||
|
||||
// ---- build_css ----
|
||||
|
||||
#[test]
|
||||
fn css_defines_bg_color() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_defines_fg_color() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains("@define-color fg #cdd6f4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_defines_all_named_colors() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {} ", name)), "missing @define-color {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_contains_window_rule() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains("window {"));
|
||||
assert!(css.contains("background-color: @bg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_contains_popup_entry_class() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains(".popup-entry {"), "css: {}", &css[300..600]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_contains_note_card_class() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains(".note-card {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_contains_type_chip_class() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains(".type-chip {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_contains_sidebar_row_class() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains(".sidebar-row {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_appends_user_css() {
|
||||
let user = ".my-override { color: hotpink; }";
|
||||
let css = build_css(&Palette::default(), Some(user));
|
||||
assert!(css.contains(".my-override { color: hotpink; }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_without_user_css_omits_user_rules() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(!css.contains(".my-override"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_reflects_custom_palette_colors() {
|
||||
let mut p = Palette::default();
|
||||
p.background = "#deadbe".into();
|
||||
p.color4 = "#cafe00".into();
|
||||
let css = build_css(&p, None);
|
||||
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
|
||||
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_from_wal_palette_uses_wal_colors() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
let css = build_css(&p, None);
|
||||
assert!(css.contains("@define-color bg #1a1b26"), "css: {}", &css[..300]);
|
||||
assert!(css.contains("@define-color fg #c0caf5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_returns_valid_palette() {
|
||||
// No wal file in CI/test env; should return non-empty strings starting with #
|
||||
let palette = load_palette();
|
||||
assert!(!palette.background.is_empty());
|
||||
assert!(palette.background.starts_with('#'), "bg: {}", palette.background);
|
||||
assert!(!palette.foreground.is_empty());
|
||||
assert!(palette.color4.starts_with('#'));
|
||||
}
|
||||
}
|
||||
404
breadpad-shared/src/types.rs
Normal file
404
breadpad-shared/src/types.rs
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NoteType {
|
||||
Todo,
|
||||
Reminder,
|
||||
Idea,
|
||||
Note,
|
||||
Question,
|
||||
#[serde(untagged)]
|
||||
Tag(String),
|
||||
}
|
||||
|
||||
impl NoteType {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"todo" => NoteType::Todo,
|
||||
"reminder" => NoteType::Reminder,
|
||||
"idea" => NoteType::Idea,
|
||||
"note" => NoteType::Note,
|
||||
"question" => NoteType::Question,
|
||||
other => NoteType::Tag(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
NoteType::Todo => "todo",
|
||||
NoteType::Reminder => "reminder",
|
||||
NoteType::Idea => "idea",
|
||||
NoteType::Note => "note",
|
||||
NoteType::Question => "question",
|
||||
NoteType::Tag(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_builtin() -> &'static [&'static str] {
|
||||
&["todo", "reminder", "idea", "note", "question"]
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NoteType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecurrenceRule(pub String);
|
||||
|
||||
impl RecurrenceRule {
|
||||
pub fn new(rrule: impl Into<String>) -> Self {
|
||||
RecurrenceRule(rrule.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub body: String,
|
||||
#[serde(rename = "type")]
|
||||
pub note_type: NoteType,
|
||||
pub time: Option<DateTime<Utc>>,
|
||||
pub rrule: Option<RecurrenceRule>,
|
||||
pub done: bool,
|
||||
pub workspace: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
pub snoozed_until: Option<DateTime<Utc>>,
|
||||
pub completed: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub caldav_uid: Option<String>,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
|
||||
Note {
|
||||
id: uuid::Uuid::new_v4()
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(6)
|
||||
.collect(),
|
||||
body,
|
||||
note_type,
|
||||
time: None,
|
||||
rrule: None,
|
||||
done: false,
|
||||
workspace,
|
||||
created: Utc::now(),
|
||||
snoozed_until: None,
|
||||
completed: None,
|
||||
tags: Vec::new(),
|
||||
caldav_uid: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_time(&self) -> Option<DateTime<Utc>> {
|
||||
self.snoozed_until.or(self.time)
|
||||
}
|
||||
|
||||
pub fn mark_done(&mut self) {
|
||||
self.done = true;
|
||||
self.completed = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClassificationResult {
|
||||
pub note_type: NoteType,
|
||||
pub time: Option<DateTime<Utc>>,
|
||||
pub rrule: Option<RecurrenceRule>,
|
||||
pub body: String,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
// ---- NoteType ----
|
||||
|
||||
#[test]
|
||||
fn note_type_from_str_all_builtins() {
|
||||
assert_eq!(NoteType::from_str("todo"), NoteType::Todo);
|
||||
assert_eq!(NoteType::from_str("reminder"), NoteType::Reminder);
|
||||
assert_eq!(NoteType::from_str("idea"), NoteType::Idea);
|
||||
assert_eq!(NoteType::from_str("note"), NoteType::Note);
|
||||
assert_eq!(NoteType::from_str("question"), NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_from_str_case_insensitive() {
|
||||
assert_eq!(NoteType::from_str("TODO"), NoteType::Todo);
|
||||
assert_eq!(NoteType::from_str("Reminder"), NoteType::Reminder);
|
||||
assert_eq!(NoteType::from_str("IDEA"), NoteType::Idea);
|
||||
assert_eq!(NoteType::from_str("Note"), NoteType::Note);
|
||||
assert_eq!(NoteType::from_str("QUESTION"), NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_custom_tag_preserved() {
|
||||
let nt = NoteType::from_str("standup");
|
||||
assert!(matches!(nt, NoteType::Tag(ref s) if s == "standup"));
|
||||
assert_eq!(nt.as_str(), "standup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_empty_string_becomes_tag() {
|
||||
let nt = NoteType::from_str("");
|
||||
assert!(matches!(nt, NoteType::Tag(ref s) if s.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_all_builtin_round_trip() {
|
||||
for &s in NoteType::all_builtin() {
|
||||
assert_eq!(NoteType::from_str(s).as_str(), s, "round-trip failed for '{}'", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_display_matches_as_str() {
|
||||
for &s in NoteType::all_builtin() {
|
||||
let nt = NoteType::from_str(s);
|
||||
assert_eq!(nt.to_string(), nt.as_str());
|
||||
}
|
||||
let tag = NoteType::Tag("weekly".into());
|
||||
assert_eq!(tag.to_string(), "weekly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_serializes_lowercase() {
|
||||
let json = serde_json::to_string(&NoteType::Todo).unwrap();
|
||||
assert_eq!(json, r#""todo""#);
|
||||
let json = serde_json::to_string(&NoteType::Reminder).unwrap();
|
||||
assert_eq!(json, r#""reminder""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_tag_serializes_as_string() {
|
||||
let json = serde_json::to_string(&NoteType::Tag("meeting".into())).unwrap();
|
||||
assert_eq!(json, r#""meeting""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_deserializes_from_string() {
|
||||
let nt: NoteType = serde_json::from_str(r#""todo""#).unwrap();
|
||||
assert_eq!(nt, NoteType::Todo);
|
||||
let nt: NoteType = serde_json::from_str(r#""question""#).unwrap();
|
||||
assert_eq!(nt, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_type_unknown_deserializes_as_tag() {
|
||||
let nt: NoteType = serde_json::from_str(r#""standup""#).unwrap();
|
||||
assert_eq!(nt, NoteType::Tag("standup".into()));
|
||||
}
|
||||
|
||||
// ---- RecurrenceRule ----
|
||||
|
||||
#[test]
|
||||
fn recurrence_rule_new_stores_value() {
|
||||
let r = RecurrenceRule::new("RRULE:FREQ=DAILY");
|
||||
assert_eq!(r.as_str(), "RRULE:FREQ=DAILY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurrence_rule_serde_round_trip() {
|
||||
let r = RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=FR");
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
let decoded: RecurrenceRule = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.as_str(), r.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurrence_rule_from_string_owned() {
|
||||
let s = String::from("RRULE:FREQ=MONTHLY");
|
||||
let r = RecurrenceRule::new(s);
|
||||
assert_eq!(r.as_str(), "RRULE:FREQ=MONTHLY");
|
||||
}
|
||||
|
||||
// ---- Note::new ----
|
||||
|
||||
#[test]
|
||||
fn note_new_defaults() {
|
||||
let note = Note::new("body text".into(), NoteType::Note, Some("3".into()));
|
||||
assert_eq!(note.body, "body text");
|
||||
assert_eq!(note.note_type, NoteType::Note);
|
||||
assert_eq!(note.workspace, Some("3".into()));
|
||||
assert!(!note.done);
|
||||
assert!(note.completed.is_none());
|
||||
assert!(note.time.is_none());
|
||||
assert!(note.rrule.is_none());
|
||||
assert!(note.snoozed_until.is_none());
|
||||
assert!(note.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_new_without_workspace() {
|
||||
let note = Note::new("x".into(), NoteType::Idea, None);
|
||||
assert!(note.workspace.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_id_is_six_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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_id_is_unique() {
|
||||
let ids: Vec<String> = (0..100).map(|_| Note::new("x".into(), NoteType::Note, None).id).collect();
|
||||
let unique: std::collections::HashSet<&str> = ids.iter().map(|s| s.as_str()).collect();
|
||||
assert_eq!(unique.len(), 100, "found duplicate IDs in 100 notes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_created_is_recent() {
|
||||
let before = Utc::now();
|
||||
let note = Note::new("x".into(), NoteType::Note, None);
|
||||
let after = Utc::now();
|
||||
assert!(note.created >= before && note.created <= after);
|
||||
}
|
||||
|
||||
// ---- Note::mark_done ----
|
||||
|
||||
#[test]
|
||||
fn note_mark_done_sets_done_and_completed() {
|
||||
let before = Utc::now();
|
||||
let mut note = Note::new("task".into(), NoteType::Todo, None);
|
||||
note.mark_done();
|
||||
let after = Utc::now();
|
||||
|
||||
assert!(note.done);
|
||||
let completed = note.completed.expect("completed should be set after mark_done");
|
||||
assert!(completed >= before && completed <= after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_mark_done_twice_updates_timestamp() {
|
||||
let mut note = Note::new("task".into(), NoteType::Todo, None);
|
||||
note.mark_done();
|
||||
let first = note.completed.unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
note.mark_done();
|
||||
let second = note.completed.unwrap();
|
||||
assert!(second >= first);
|
||||
}
|
||||
|
||||
// ---- Note::effective_time ----
|
||||
|
||||
#[test]
|
||||
fn effective_time_none_when_nothing_set() {
|
||||
let note = Note::new("x".into(), NoteType::Note, None);
|
||||
assert_eq!(note.effective_time(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_time_returns_time_when_no_snooze() {
|
||||
let mut note = Note::new("x".into(), NoteType::Reminder, None);
|
||||
let t = Utc::now() + Duration::hours(1);
|
||||
note.time = Some(t);
|
||||
assert_eq!(note.effective_time(), Some(t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_time_prefers_snoozed_over_time() {
|
||||
let mut note = Note::new("x".into(), NoteType::Reminder, None);
|
||||
let original = Utc::now() + Duration::hours(1);
|
||||
let snoozed = Utc::now() + Duration::hours(2);
|
||||
note.time = Some(original);
|
||||
note.snoozed_until = Some(snoozed);
|
||||
assert_eq!(note.effective_time(), Some(snoozed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_time_snoozed_without_original() {
|
||||
let mut note = Note::new("x".into(), NoteType::Reminder, None);
|
||||
let snoozed = Utc::now() + Duration::hours(3);
|
||||
note.snoozed_until = Some(snoozed);
|
||||
assert_eq!(note.effective_time(), Some(snoozed));
|
||||
}
|
||||
|
||||
// ---- Note serde ----
|
||||
|
||||
#[test]
|
||||
fn note_serde_round_trip_minimal() {
|
||||
let note = Note::new("buy milk".into(), NoteType::Todo, None);
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let decoded: Note = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.id, note.id);
|
||||
assert_eq!(decoded.body, note.body);
|
||||
assert_eq!(decoded.note_type, note.note_type);
|
||||
assert!(!decoded.done);
|
||||
assert!(decoded.time.is_none());
|
||||
assert!(decoded.rrule.is_none());
|
||||
assert!(decoded.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_serde_with_rrule_and_workspace() {
|
||||
let mut note = Note::new("standup".into(), NoteType::Reminder, Some("1".into()));
|
||||
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO"));
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let decoded: Note = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.workspace, Some("1".into()));
|
||||
assert_eq!(decoded.rrule.unwrap().as_str(), "RRULE:FREQ=WEEKLY;BYDAY=MO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_serde_done_with_completed() {
|
||||
let mut note = Note::new("chore".into(), NoteType::Todo, None);
|
||||
note.mark_done();
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let decoded: Note = serde_json::from_str(&json).unwrap();
|
||||
assert!(decoded.done);
|
||||
assert!(decoded.completed.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_serde_with_tags() {
|
||||
let mut note = Note::new("x".into(), NoteType::Note, None);
|
||||
note.tags = vec!["work".into(), "urgent".into()];
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let decoded: Note = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.tags, vec!["work", "urgent"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_json_uses_type_key() {
|
||||
let note = Note::new("x".into(), NoteType::Reminder, None);
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
assert!(json.contains(r#""type":"reminder""#), "json: {}", json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_json_missing_tags_defaults_to_empty() {
|
||||
// Older stored notes may not have tags field
|
||||
let json = r#"{"id":"abc123","body":"test","type":"note","time":null,"rrule":null,"done":false,"workspace":null,"created":"2026-01-01T00:00:00Z","snoozed_until":null,"completed":null}"#;
|
||||
let note: Note = serde_json::from_str(json).unwrap();
|
||||
assert!(note.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_full_jsonl_example_from_readme() {
|
||||
let line = r#"{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00Z","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00Z","snoozed_until":null,"completed":null}"#;
|
||||
let note: Note = serde_json::from_str(line).unwrap();
|
||||
assert_eq!(note.id, "a1b2c3");
|
||||
assert_eq!(note.note_type, NoteType::Reminder);
|
||||
assert_eq!(note.workspace, Some("1".into()));
|
||||
assert!(!note.done);
|
||||
assert!(note.time.is_some());
|
||||
}
|
||||
}
|
||||
83
breadpad-shared/tests/classifier.rs
Normal file
83
breadpad-shared/tests/classifier.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use breadpad_shared::classifier::Classifier;
|
||||
use breadpad_shared::types::NoteType;
|
||||
use chrono::Timelike;
|
||||
|
||||
fn cl() -> Classifier {
|
||||
Classifier::load("auto", "08:00")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_is_cpu() {
|
||||
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback.
|
||||
let c = cl();
|
||||
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_falls_back_to_rule_based() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("buy milk");
|
||||
assert_eq!(r.note_type, NoteType::Todo);
|
||||
assert!(r.time.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_todo_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("fix the segfault").note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reminder_via_fallback() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("call mum at 6pm");
|
||||
assert_eq!(r.note_type, NoteType::Reminder);
|
||||
assert!(r.time.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_idea_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("what if we added a calendar view").note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_question_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("why does this fail?").note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_note_via_fallback() {
|
||||
let mut c = cl();
|
||||
assert_eq!(c.classify("meeting went well today").note_type, NoteType::Note);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_recurrence_via_fallback() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("standup every monday at 9am");
|
||||
assert!(r.rrule.is_some(), "expected rrule from fallback parser");
|
||||
assert_eq!(r.note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_custom_morning_time() {
|
||||
let mut c = Classifier::load("auto", "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();
|
||||
assert_eq!(local.hour(), 7);
|
||||
assert_eq!(local.minute(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_path_points_to_expected_location() {
|
||||
let c = cl();
|
||||
assert!(
|
||||
c.model_path.to_str().unwrap().contains("breadpad"),
|
||||
"model path: {:?}",
|
||||
c.model_path
|
||||
);
|
||||
assert!(c.model_path.to_str().unwrap().ends_with("classifier.onnx"));
|
||||
}
|
||||
198
breadpad-shared/tests/config.rs
Normal file
198
breadpad-shared/tests/config.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---- Default values ----
|
||||
|
||||
#[test]
|
||||
fn default_settings() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.default_type, "note");
|
||||
assert!(s.workspace_tag);
|
||||
assert_eq!(s.archive_after_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_snooze_options_contains_all_three() {
|
||||
let s = Settings::default();
|
||||
assert!(s.snooze_options.iter().any(|x| x == "15m"));
|
||||
assert!(s.snooze_options.iter().any(|x| x == "1h"));
|
||||
assert!(s.snooze_options.iter().any(|x| x == "tomorrow_morning"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_reminders_config() {
|
||||
let r = RemindersConfig::default();
|
||||
assert_eq!(r.default_morning, "08:00");
|
||||
assert_eq!(r.missed_grace_minutes, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
// ---- TOML deserialization ----
|
||||
|
||||
#[test]
|
||||
fn full_config_from_toml() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "todo"
|
||||
workspace_tag = false
|
||||
snooze_options = ["15m", "2h"]
|
||||
archive_after_days = 7
|
||||
|
||||
[model]
|
||||
path = "/tmp/classifier.onnx"
|
||||
tokenizer = "/tmp/tokenizer.json"
|
||||
execution_provider = "cpu"
|
||||
|
||||
[reminders]
|
||||
default_morning = "07:30"
|
||||
missed_grace_minutes = 30
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "todo");
|
||||
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.reminders.default_morning, "07:30");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_only_settings_section() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
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]
|
||||
fn default_config_serializes_to_valid_toml() {
|
||||
let cfg = Config::default();
|
||||
let serialized = toml::to_string_pretty(&cfg).unwrap();
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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;
|
||||
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- Config::save + Config::load ----
|
||||
|
||||
#[test]
|
||||
fn save_and_load_round_trip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config_path = dir.path().join("breadpad.toml");
|
||||
|
||||
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() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "note"
|
||||
workspace_tag = true
|
||||
snooze_options = ["15m", "1h", "tomorrow_morning"]
|
||||
archive_after_days = 30
|
||||
|
||||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto"
|
||||
|
||||
[reminders]
|
||||
default_morning = "08:00"
|
||||
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);
|
||||
}
|
||||
236
breadpad-shared/tests/pipeline.rs
Normal file
236
breadpad-shared/tests/pipeline.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// End-to-end pipeline tests: classify → save → reload
|
||||
//
|
||||
// These mirror what breadpad (capture) and breadman (display) do in production.
|
||||
// Both apps share the same Store path; we prove here that a note typed in the
|
||||
// popup survives the classify+save step and is visible to a fresh store handle,
|
||||
// exactly as breadman would see it on startup.
|
||||
|
||||
use breadpad_shared::classifier::Classifier;
|
||||
use breadpad_shared::store::Store;
|
||||
use breadpad_shared::types::{Note, NoteType};
|
||||
use chrono::Timelike;
|
||||
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 result = classifier.classify(text);
|
||||
|
||||
let mut note = Note::new(text.into(), user_type.clone(), None);
|
||||
|
||||
// When the user left the type at the default, let the classifier override it.
|
||||
if user_type == NoteType::from_str("note") {
|
||||
note.note_type = result.note_type;
|
||||
}
|
||||
note.time = result.time;
|
||||
note.rrule = result.rrule;
|
||||
note.body = result.body;
|
||||
|
||||
store.save_note(¬e).unwrap();
|
||||
note
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, Store) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
// Open a second Store handle pointing at the same directory — this simulates
|
||||
// breadman reading from the path that breadpad wrote to.
|
||||
fn breadman_store(dir: &TempDir) -> Store {
|
||||
Store::from_dir(dir.path()).unwrap()
|
||||
}
|
||||
|
||||
// ---- basic round-trip ----
|
||||
|
||||
#[test]
|
||||
fn todo_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
let saved = capture(&store, "buy groceries", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].id, saved.id);
|
||||
assert_eq!(notes[0].note_type, NoteType::Todo);
|
||||
assert_eq!(notes[0].body, "buy groceries");
|
||||
assert!(!notes[0].done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "what if we added dark mode", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].note_type, NoteType::Idea);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "why does the cache miss on cold start?", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].note_type, NoteType::Question);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_note_appears_in_store() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "retro went well today", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].note_type, NoteType::Note);
|
||||
}
|
||||
|
||||
// ---- reminder with time ----
|
||||
|
||||
#[test]
|
||||
fn reminder_has_time_set() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
assert!(notes[0].time.is_some(), "reminder should have a scheduled time");
|
||||
let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into();
|
||||
assert_eq!(local.hour(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reminder_body_has_time_stripped() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert!(!notes[0].body.contains("6pm"), "time phrase should be removed from body");
|
||||
assert!(notes[0].body.contains("call mum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_duration_reminder_has_time() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "check on the build in 30 minutes", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
assert!(notes[0].time.is_some());
|
||||
}
|
||||
|
||||
// ---- recurring reminder ----
|
||||
|
||||
#[test]
|
||||
fn recurring_reminder_has_rrule() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "standup every monday at 9am", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
let rrule = notes[0].rrule.as_ref().expect("should have rrule");
|
||||
assert!(rrule.as_str().contains("FREQ=WEEKLY"));
|
||||
assert!(rrule.as_str().contains("BYDAY=MO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_reminder_has_rrule() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "drink water every day at 8am", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
assert!(notes[0].rrule.as_ref().unwrap().as_str().contains("FREQ=DAILY"));
|
||||
}
|
||||
|
||||
// ---- user-forced type is respected ----
|
||||
|
||||
#[test]
|
||||
fn user_selected_type_overrides_classifier() {
|
||||
let (dir, store) = setup();
|
||||
// Text would classify as Todo, but user explicitly chose Idea
|
||||
capture(&store, "fix the login bug", NoteType::Idea);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Idea, "user chip selection should win over classifier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_selected_reminder_overrides_classifier() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "team meeting notes from today", NoteType::Reminder);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
// ---- multiple notes all appear ----
|
||||
|
||||
#[test]
|
||||
fn three_notes_all_visible_to_breadman() {
|
||||
let (dir, store) = setup();
|
||||
capture(&store, "buy milk", NoteType::from_str("note"));
|
||||
capture(&store, "what if we rewrote in Zig", NoteType::from_str("note"));
|
||||
capture(&store, "team standup went well", NoteType::from_str("note"));
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len(), 3);
|
||||
|
||||
let types: Vec<NoteType> = notes.iter().map(|n| n.note_type.clone()).collect();
|
||||
assert!(types.contains(&NoteType::Todo));
|
||||
assert!(types.contains(&NoteType::Idea));
|
||||
assert!(types.contains(&NoteType::Note));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_written_sequentially_all_survive() {
|
||||
let (dir, store) = setup();
|
||||
let n = 10u32;
|
||||
for i in 0..n {
|
||||
capture(&store, &format!("note number {}", i), NoteType::Note);
|
||||
}
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes.len() as u32, n);
|
||||
}
|
||||
|
||||
// ---- note fields are fully preserved ----
|
||||
|
||||
#[test]
|
||||
fn note_id_is_stable_after_reload() {
|
||||
let (dir, store) = setup();
|
||||
let saved = capture(&store, "check the logs", NoteType::Todo);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
assert_eq!(notes[0].id, saved.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_created_timestamp_preserved() {
|
||||
let (dir, store) = setup();
|
||||
let saved = capture(&store, "morning standup", NoteType::Note);
|
||||
|
||||
let notes = breadman_store(&dir).load_all().unwrap();
|
||||
// Timestamps should be equal within 1 second (serde round-trips subsecond precision)
|
||||
let diff = (notes[0].created - saved.created).num_seconds().abs();
|
||||
assert!(diff <= 1, "created timestamp drifted by {}s", diff);
|
||||
}
|
||||
|
||||
// ---- store isolation: two separate runs don't bleed ----
|
||||
|
||||
#[test]
|
||||
fn separate_store_dirs_are_isolated() {
|
||||
let (dir_a, store_a) = setup();
|
||||
let (dir_b, store_b) = setup();
|
||||
capture(&store_a, "note for session A", NoteType::Note);
|
||||
capture(&store_b, "note for session B", NoteType::Note);
|
||||
|
||||
let notes_a = breadman_store(&dir_a).load_all().unwrap();
|
||||
let notes_b = breadman_store(&dir_b).load_all().unwrap();
|
||||
assert_eq!(notes_a.len(), 1);
|
||||
assert_eq!(notes_b.len(), 1);
|
||||
assert_ne!(notes_a[0].id, notes_b[0].id);
|
||||
}
|
||||
382
breadpad-shared/tests/store.rs
Normal file
382
breadpad-shared/tests/store.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
use breadpad_shared::store::Store;
|
||||
use breadpad_shared::types::{Note, NoteType, RecurrenceRule};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn mk() -> (TempDir, Store) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn note(body: &str, nt: NoteType) -> Note {
|
||||
Note::new(body.into(), nt, None)
|
||||
}
|
||||
|
||||
// ---- Empty state ----
|
||||
|
||||
#[test]
|
||||
fn empty_store_loads_empty_vec() {
|
||||
let (_dir, store) = mk();
|
||||
let notes = store.load_all().unwrap();
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_archive_loads_empty_vec() {
|
||||
let (_dir, store) = mk();
|
||||
let archive = store.load_archive().unwrap();
|
||||
assert!(archive.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_id_returns_none_on_empty_store() {
|
||||
let (_dir, store) = mk();
|
||||
assert!(store.get_by_id("missing").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---- save_note + load_all ----
|
||||
|
||||
#[test]
|
||||
fn save_and_load_single() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("buy milk", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, n.id);
|
||||
assert_eq!(loaded[0].body, "buy milk");
|
||||
assert_eq!(loaded[0].note_type, NoteType::Todo);
|
||||
assert!(!loaded[0].done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_three_notes_all_loaded() {
|
||||
let (_dir, store) = mk();
|
||||
let a = note("alpha", NoteType::Idea);
|
||||
let b = note("beta", NoteType::Note);
|
||||
let c = note("gamma", NoteType::Question);
|
||||
store.save_note(&a).unwrap();
|
||||
store.save_note(&b).unwrap();
|
||||
store.save_note(&c).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 3);
|
||||
let bodies: Vec<&str> = loaded.iter().map(|n| n.body.as_str()).collect();
|
||||
assert!(bodies.contains(&"alpha"));
|
||||
assert!(bodies.contains(&"beta"));
|
||||
assert!(bodies.contains(&"gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_note_preserves_all_fields() {
|
||||
let (_dir, store) = mk();
|
||||
let mut n = Note::new("standup".into(), NoteType::Reminder, Some("2".into()));
|
||||
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO"));
|
||||
n.tags = vec!["work".into()];
|
||||
let t = Utc::now();
|
||||
n.time = Some(t);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.workspace, Some("2".into()));
|
||||
assert_eq!(loaded.rrule.unwrap().as_str(), "RRULE:FREQ=WEEKLY;BYDAY=MO");
|
||||
assert_eq!(loaded.tags, vec!["work"]);
|
||||
assert!(loaded.time.is_some());
|
||||
}
|
||||
|
||||
// ---- update_note ----
|
||||
|
||||
#[test]
|
||||
fn update_note_changes_body() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("original", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
let mut updated = n.clone();
|
||||
updated.body = "updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_note_changes_type() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("task", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
let mut updated = n.clone();
|
||||
updated.note_type = NoteType::Todo;
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_note_does_not_affect_other_notes() {
|
||||
let (_dir, store) = mk();
|
||||
let n1 = note("first", NoteType::Note);
|
||||
let n2 = note("second", NoteType::Todo);
|
||||
store.save_note(&n1).unwrap();
|
||||
store.save_note(&n2).unwrap();
|
||||
|
||||
let mut updated = n1.clone();
|
||||
updated.body = "first-updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let second = store.get_by_id(&n2.id).unwrap().unwrap();
|
||||
assert_eq!(second.body, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_nonexistent_id_leaves_store_intact() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("real", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut ghost = n.clone();
|
||||
ghost.id = "ghost1".into();
|
||||
ghost.body = "ghost".into();
|
||||
store.update_note(&ghost).unwrap();
|
||||
|
||||
let notes = store.load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].body, "real");
|
||||
}
|
||||
|
||||
// ---- mark_done via update ----
|
||||
|
||||
#[test]
|
||||
fn mark_done_persists_through_update() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("finish task", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut done = n.clone();
|
||||
done.mark_done();
|
||||
store.update_note(&done).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert!(loaded.done);
|
||||
assert!(loaded.completed.is_some());
|
||||
}
|
||||
|
||||
// ---- delete_note ----
|
||||
|
||||
#[test]
|
||||
fn delete_removes_only_target() {
|
||||
let (_dir, store) = mk();
|
||||
let keep = note("keep", NoteType::Note);
|
||||
let del = note("delete me", NoteType::Note);
|
||||
store.save_note(&keep).unwrap();
|
||||
store.save_note(&del).unwrap();
|
||||
|
||||
store.delete_note(&del.id).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, keep.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_all_leaves_empty_store() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("only note", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
store.delete_note(&n.id).unwrap();
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_id_is_noop() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("real note", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
store.delete_note("no-such-id").unwrap();
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// ---- get_by_id ----
|
||||
|
||||
#[test]
|
||||
fn get_by_id_finds_correct_note() {
|
||||
let (_dir, store) = mk();
|
||||
let a = note("alpha", NoteType::Idea);
|
||||
let b = note("beta", NoteType::Idea);
|
||||
store.save_note(&a).unwrap();
|
||||
store.save_note(&b).unwrap();
|
||||
|
||||
let found = store.get_by_id(&a.id).unwrap().unwrap();
|
||||
assert_eq!(found.body, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_id_returns_none_for_missing() {
|
||||
let (_dir, store) = mk();
|
||||
store.save_note(¬e("x", NoteType::Note)).unwrap();
|
||||
assert!(store.get_by_id("nope").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---- rotate_archive ----
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_moves_old_done_notes() {
|
||||
let (_dir, store) = mk();
|
||||
|
||||
let mut old_done = note("old task", NoteType::Todo);
|
||||
old_done.done = true;
|
||||
old_done.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&old_done).unwrap();
|
||||
|
||||
let mut recent_done = note("recent task", NoteType::Todo);
|
||||
recent_done.done = true;
|
||||
recent_done.completed = Some(Utc::now() - Duration::days(1));
|
||||
store.save_note(&recent_done).unwrap();
|
||||
|
||||
let active = note("active task", NoteType::Todo);
|
||||
store.save_note(&active).unwrap();
|
||||
|
||||
let moved = store.rotate_archive(30).unwrap();
|
||||
assert_eq!(moved, 1);
|
||||
|
||||
let remaining = store.load_all().unwrap();
|
||||
assert_eq!(remaining.len(), 2);
|
||||
let remaining_ids: Vec<&str> = remaining.iter().map(|n| n.id.as_str()).collect();
|
||||
assert!(!remaining_ids.contains(&old_done.id.as_str()), "old note should be archived");
|
||||
assert!(remaining_ids.contains(&recent_done.id.as_str()));
|
||||
assert!(remaining_ids.contains(&active.id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_writes_to_archive_file() {
|
||||
let (_dir, store) = mk();
|
||||
let mut old = note("archived task", NoteType::Todo);
|
||||
old.done = true;
|
||||
old.completed = Some(Utc::now() - Duration::days(35));
|
||||
store.save_note(&old).unwrap();
|
||||
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
let archived = store.load_archive().unwrap();
|
||||
assert_eq!(archived.len(), 1);
|
||||
assert_eq!(archived[0].id, old.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_appends_to_existing_archive() {
|
||||
let (_dir, store) = mk();
|
||||
|
||||
for i in 0..3u32 {
|
||||
let mut n = note(&format!("old {}", i), NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&n).unwrap();
|
||||
}
|
||||
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
// Add more old notes and rotate again
|
||||
for i in 3..5u32 {
|
||||
let mut n = note(&format!("old {}", i), NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&n).unwrap();
|
||||
}
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
let archived = store.load_archive().unwrap();
|
||||
assert_eq!(archived.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_zero_when_nothing_qualifies() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("active", NoteType::Note);
|
||||
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_ignores_undone_notes_no_matter_how_old() {
|
||||
let (_dir, store) = mk();
|
||||
let mut n = note("old but undone", NoteType::Todo);
|
||||
n.done = false;
|
||||
// Set created to far past but not done
|
||||
n.completed = Some(Utc::now() - Duration::days(100));
|
||||
store.save_note(&n).unwrap();
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
}
|
||||
|
||||
// ---- Fault tolerance ----
|
||||
|
||||
#[test]
|
||||
fn malformed_jsonl_line_is_skipped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let notes_path = dir.path().join("notes.jsonl");
|
||||
|
||||
let valid = note("valid note", NoteType::Note);
|
||||
let valid_line = serde_json::to_string(&valid).unwrap();
|
||||
fs::write(
|
||||
¬es_path,
|
||||
format!("{}\n{{not valid json}}\n{}\n", valid_line, valid_line),
|
||||
).unwrap();
|
||||
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
let loaded = store.load_all().unwrap();
|
||||
// Two valid lines, one bad line skipped
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert!(loaded.iter().all(|n| n.body == "valid note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_in_jsonl_are_skipped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let notes_path = dir.path().join("notes.jsonl");
|
||||
|
||||
let n = note("hello", NoteType::Note);
|
||||
let line = serde_json::to_string(&n).unwrap();
|
||||
fs::write(¬es_path, format!("\n\n{}\n\n", line)).unwrap();
|
||||
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
}
|
||||
|
||||
// ---- Atomic write ----
|
||||
|
||||
#[test]
|
||||
fn no_tmp_file_left_after_update() {
|
||||
let (dir, store) = mk();
|
||||
let n = note("task", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut updated = n.clone();
|
||||
updated.body = "updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let tmp = dir.path().join("notes.tmp");
|
||||
assert!(!tmp.exists(), ".tmp file should be renamed after write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_writes_atomically_via_rename() {
|
||||
// Verify the file content is consistent after an update (no partial writes visible)
|
||||
let (_dir, store) = mk();
|
||||
for i in 0..10u32 {
|
||||
store.save_note(¬e(&format!("note {}", i), NoteType::Note)).unwrap();
|
||||
}
|
||||
|
||||
let first = store.load_all().unwrap()[0].clone();
|
||||
let mut updated = first.clone();
|
||||
updated.body = "modified".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 10);
|
||||
assert!(loaded.iter().any(|n| n.body == "modified"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue