Skip to main content

oxios_markdown/
types.rs

1//! Shared types for the oxios-markdown crate.
2//!
3//! Core data structures used across all modules.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8// ============================================================================
9// Directory & Filename Constants
10// ============================================================================
11
12/// Root directory identifier.
13pub const DIR_USER_ROOT: &str = "/";
14
15/// Archive directory name.
16pub const DIR_ARCHIVE: &str = "archive";
17
18/// Media directory name.
19pub const DIR_MEDIA: &str = "media";
20
21/// Journal directory name.
22pub const DIR_JOURNAL: &str = "journal";
23
24/// Habits directory name.
25pub const DIR_HABITS: &str = "habits";
26
27/// Insights directory name.
28pub const DIR_INSIGHTS: &str = "insights";
29
30/// Chat filename.
31pub const CHAT_FILENAME: &str = "Chat.md";
32
33/// Later filename.
34pub const LATER_FILENAME: &str = "Later.md";
35
36/// Done filename.
37pub const DONE_FILENAME: &str = "Done.md";
38
39/// Shop filename.
40pub const SHOP_FILENAME: &str = "Shop.md";
41
42/// Watch filename.
43pub const WATCH_FILENAME: &str = "Watch.md";
44
45/// Read filename.
46pub const READ_FILENAME: &str = "Read.md";
47
48/// Pomodoro task marker.
49pub const POMODORO_TASK: &str = "Finished a break";
50
51/// Markdown file extension.
52pub const MD_EXT: &str = ".md";
53
54// ============================================================================
55// File / Entry Types
56// ============================================================================
57
58/// A file or directory entry in the knowledge base.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct FileEntry {
61    /// Filename with extension (e.g., "Rust.md").
62    pub name: String,
63    /// MD5 hash (first 11 characters) for compact identification.
64    pub hash: String,
65    /// Display name: capitalized, without extension.
66    pub display_name: String,
67    /// Creation/modification time in milliseconds since epoch.
68    pub ctime: i64,
69    /// Whether the file has non-whitespace content.
70    pub has_content: bool,
71    /// Whether this is a directory.
72    pub is_dir: bool,
73    /// Parent directory path.
74    pub parent_dir: String,
75}
76
77impl FileEntry {
78    /// Create a new file entry.
79    pub fn new(
80        name: String,
81        hash: String,
82        display_name: String,
83        ctime: i64,
84        has_content: bool,
85        is_dir: bool,
86        parent_dir: String,
87    ) -> Self {
88        Self {
89            name,
90            hash,
91            display_name,
92            ctime,
93            has_content,
94            is_dir,
95            parent_dir,
96        }
97    }
98}
99
100// ============================================================================
101// Error Types
102// ============================================================================
103
104/// Filesystem errors for the knowledge base.
105#[derive(Debug, thiserror::Error)]
106pub enum FsError {
107    /// Storage quota exceeded.
108    #[error("storage quota exceeded")]
109    QuotaExceeded,
110    /// Unsafe path (path traversal attempt).
111    #[error("unsafe path, possible security issue")]
112    UnsafePath,
113    /// Cannot reverse a hash to find the original filename.
114    #[error("cannot unhash, maybe the file is missing")]
115    CannotUnhash,
116    /// File too large to read or write in one operation.
117    #[error("file too large")]
118    TooLarge,
119    /// IO error.
120    #[error("{0}")]
121    Io(#[from] std::io::Error),
122}
123
124// ============================================================================
125// Sync Types
126// ============================================================================
127
128/// Sync status: operation succeeded.
129pub const STATUS_OK: &str = "ok";
130
131/// Sync status: file not modified.
132pub const STATUS_NOT_MODIFIED: &str = "notModified";
133
134/// Sync status: file was updated on server.
135pub const STATUS_UPDATED_ON_SERVER: &str = "updatedOnServer";
136
137/// Sync status: file was merged from both sides.
138pub const STATUS_MERGED: &str = "merged";
139
140/// Maximum size for a single text sync (5 MB).
141pub const MAX_TEXT_SIZE: usize = 5 * 1024 * 1024;
142
143/// Maximum size for a batch text sync (10 MB).
144pub const MAX_TEXTS_SIZE: usize = 10 * 1024 * 1024;
145
146/// Maximum size for a single media sync (20 MB).
147pub const MAX_MEDIA_SIZE: usize = 20 * 1024 * 1024;
148
149/// Maximum size for a batch media sync (512 KB).
150pub const MAX_MEDIAS_SIZE: usize = 512 * 1024;
151
152/// Maximum size for an auth token (4 KB).
153pub const MAX_TOKEN_SIZE: usize = 4 * 1024;
154
155/// A file in the sync protocol.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct SyncFile {
158    /// Status of this file in the sync response.
159    pub status: String,
160    /// File path (relative to knowledge base root).
161    pub path: String,
162    /// Last modified timestamp (ms since epoch).
163    #[serde(rename = "lastModified")]
164    pub last_modified: i64,
165    /// Client's last modification time.
166    #[serde(rename = "clientLastModified", default)]
167    pub client_last_modified: i64,
168    /// Client's last sync time.
169    #[serde(rename = "clientLastSynced", default)]
170    pub client_last_synced: i64,
171    /// File content.
172    #[serde(default)]
173    pub content: String,
174}
175
176/// A batch sync request from the client.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SyncRequest {
179    /// Modified files from the client.
180    pub modified: Vec<SyncFile>,
181    /// Deleted file paths from the client.
182    pub deleted: Vec<String>,
183    /// Client's known directory timestamps.
184    pub timestamps: HashMap<String, i64>,
185}
186
187/// A sync response to the client.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct SyncResponse {
190    /// Overall sync status.
191    pub status: String,
192    /// Files that need to be sent to the client.
193    #[serde(default)]
194    pub files: Vec<SyncFile>,
195    /// Current directory timestamps on the server.
196    #[serde(default)]
197    pub timestamps: HashMap<String, i64>,
198    /// Rename map: new_path → old_path.
199    #[serde(default)]
200    pub renames: HashMap<String, String>,
201}
202
203impl Default for SyncResponse {
204    fn default() -> Self {
205        SyncResponse {
206            status: STATUS_OK.to_string(),
207            files: vec![],
208            timestamps: HashMap::new(),
209            renames: HashMap::new(),
210        }
211    }
212}
213
214/// Sync-specific errors.
215#[derive(Debug, thiserror::Error)]
216pub enum SyncError {
217    /// Invalid JSON in the request.
218    #[error("invalid JSON")]
219    InvalidJson,
220    /// File not found.
221    #[error("file not found")]
222    NotFound,
223    /// Storage quota exceeded.
224    #[error("quota exceeded")]
225    QuotaExceeded,
226    /// Storage layer error.
227    #[error("storage error: {0}")]
228    Storage(String),
229    /// Internal error.
230    #[error("internal error: {0}")]
231    Internal(String),
232}
233
234impl From<FsError> for SyncError {
235    fn from(err: FsError) -> Self {
236        match err {
237            FsError::QuotaExceeded => SyncError::QuotaExceeded,
238            _ => SyncError::Storage(err.to_string()),
239        }
240    }
241}
242
243// ============================================================================
244// Habits Types
245// ============================================================================
246
247/// Per-year habit map: day-of-year → status (0=skipped, 1=completed).
248pub type YearHabits = HashMap<i32, i32>;
249
250/// All habits: habit name → year data.
251pub type Habits = HashMap<String, YearHabits>;
252
253/// Habit skipped marker.
254pub const HABIT_SKIPPED: &str = "⚪️";
255
256/// Habit completed marker.
257pub const HABIT_COMPLETED: &str = "🟢";
258
259/// Habit completed at weekend marker.
260pub const HABIT_COMPLETED_AT_WEEKEND: &str = "🟡";
261
262/// Mood habit name.
263pub const MOOD_HABIT: &str = "Mood";
264
265/// Default mood emojis (index = mood level).
266pub const MOOD_EMOJIS: &[&str] = &["⚪️", "🤕", "😔", "😐", "🙂", "😊"];
267
268// ============================================================================
269// Schedule Types
270// ============================================================================
271
272/// A scheduled task.
273#[derive(Debug, Clone, Default, Serialize, Deserialize)]
274pub struct Schedule {
275    /// Target filename.
276    pub filename: String,
277    /// Scheduled timestamp (ms since epoch).
278    pub scheduled_at: i64,
279    /// Cron expression (e.g., "9:00").
280    pub cron: String,
281    /// Command placeholder (for future use).
282    #[serde(default)]
283    pub cmd: String,
284}
285
286// ============================================================================
287// Knowledge Provenance Types (RFC-022)
288// ============================================================================
289
290/// Source of a knowledge note write.
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292pub enum NoteSource {
293    /// PersistenceHook (heuristic or reflection).
294    Hook,
295    /// Agent tool-calling (knowledge write action).
296    Tool,
297    /// Web UI "save to knowledge" button.
298    Ui,
299    /// Knowledge Dream curation pass.
300    Dream,
301}
302
303/// Content quality stage of a knowledge note.
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
305pub enum NoteQuality {
306    /// Agent-generated, not yet curated.
307    Raw,
308    /// Dream has cleaned up conversational artifacts.
309    Curated,
310    /// Dream has refined and enriched (future).
311    Refined,
312}
313
314/// Provenance metadata for agent-originated knowledge writes (RFC-022).
315///
316/// Serialized as the `oxios:` key inside YAML frontmatter.
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct NoteMeta {
319    /// Who created this note.
320    pub author: String,
321    /// How the save was triggered.
322    pub source: NoteSource,
323    /// Content quality stage.
324    pub quality: NoteQuality,
325    /// Whether Dream should process this note.
326    pub needs_review: bool,
327    /// Originating session ID.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub session_id: Option<String>,
330    /// Message index in the session.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub message_index: Option<usize>,
333    /// When the note was first saved (ISO 8601).
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub saved_at: Option<String>,
336}
337
338// ============================================================================
339// Knowledge Config Types
340// ============================================================================
341
342/// User knowledge base configuration.
343///
344/// Stored as `config.json` in the knowledge base root.
345/// Decoupled from any server-specific config.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct KnowledgeConfig {
348    /// Language code (e.g., "en", "ko").
349    #[serde(default = "default_language")]
350    pub language: String,
351    /// Timezone string (e.g., "+09:00", "UTC").
352    #[serde(default = "default_timezone")]
353    pub timezone: String,
354    /// Move-to commands (quick file organization).
355    #[serde(default)]
356    pub move_to_commands: Vec<String>,
357    /// Pomodoro timer duration in minutes.
358    #[serde(default = "default_pomodoro_duration")]
359    pub pomodoro_duration_in_minutes: i64,
360    /// Scheduled tasks.
361    #[serde(default)]
362    pub schedules: Vec<Schedule>,
363    /// Quick commands.
364    #[serde(default)]
365    pub quick_commands: Vec<String>,
366    /// Whether to show two emojis per button.
367    #[serde(default)]
368    pub two_emojis_enabled: bool,
369    /// Mode: "chat", "full", "tasks", "notes", "journal".
370    #[serde(default = "default_mode")]
371    pub mode: String,
372    /// Whether quick habits are enabled.
373    #[serde(default)]
374    pub quick_habits_enabled: bool,
375    /// Associated channel IDs.
376    #[serde(default)]
377    pub channels: Vec<i64>,
378}
379
380fn default_language() -> String {
381    "en".to_string()
382}
383fn default_timezone() -> String {
384    "UTC".to_string()
385}
386fn default_pomodoro_duration() -> i64 {
387    50
388}
389fn default_mode() -> String {
390    "full".to_string()
391}
392
393impl Default for KnowledgeConfig {
394    fn default() -> Self {
395        Self {
396            language: default_language(),
397            timezone: default_timezone(),
398            move_to_commands: vec![],
399            pomodoro_duration_in_minutes: default_pomodoro_duration(),
400            schedules: vec![],
401            quick_commands: vec![],
402            two_emojis_enabled: false,
403            mode: default_mode(),
404            quick_habits_enabled: false,
405            channels: vec![],
406        }
407    }
408}
409
410/// Chat/Inbox mode constants.
411pub const MODE_CHAT: &str = "chat";
412/// Full mode constant.
413pub const MODE_FULL: &str = "full";
414/// Tasks-only mode constant.
415pub const MODE_TASKS: &str = "tasks";
416/// Notes-only mode constant.
417pub const MODE_NOTES: &str = "notes";
418/// Journal-only mode constant.
419pub const MODE_JOURNAL: &str = "journal";
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_file_entry_new() {
427        let entry = FileEntry::new(
428            "Rust.md".to_string(),
429            "abc12345678".to_string(),
430            "Rust".to_string(),
431            1700000000000,
432            true,
433            false,
434            "/notes".to_string(),
435        );
436        assert_eq!(entry.name, "Rust.md");
437        assert_eq!(entry.hash, "abc12345678");
438        assert_eq!(entry.display_name, "Rust");
439        assert!(entry.has_content);
440        assert!(!entry.is_dir);
441        assert_eq!(entry.parent_dir, "/notes");
442    }
443
444    #[test]
445    fn test_file_entry_serialization() {
446        let entry = FileEntry::new(
447            "Test.md".to_string(),
448            "hash".to_string(),
449            "Test".to_string(),
450            1000,
451            false,
452            true,
453            "/".to_string(),
454        );
455        let json = serde_json::to_string(&entry).unwrap();
456        let restored: FileEntry = serde_json::from_str(&json).unwrap();
457        assert_eq!(restored.name, entry.name);
458        assert!(restored.is_dir);
459        assert!(!restored.has_content);
460    }
461
462    #[test]
463    fn test_fs_error_display() {
464        assert_eq!(FsError::QuotaExceeded.to_string(), "storage quota exceeded");
465        assert_eq!(
466            FsError::UnsafePath.to_string(),
467            "unsafe path, possible security issue"
468        );
469        assert_eq!(
470            FsError::CannotUnhash.to_string(),
471            "cannot unhash, maybe the file is missing"
472        );
473    }
474
475    #[test]
476    fn test_sync_response_default() {
477        let resp = SyncResponse::default();
478        assert_eq!(resp.status, STATUS_OK);
479        assert!(resp.files.is_empty());
480        assert!(resp.timestamps.is_empty());
481        assert!(resp.renames.is_empty());
482    }
483
484    #[test]
485    fn test_sync_file_serialization() {
486        let file = SyncFile {
487            status: STATUS_OK.to_string(),
488            path: "notes/Test.md".to_string(),
489            last_modified: 1700000000000,
490            client_last_modified: 1700000000000,
491            client_last_synced: 1700000000000,
492            content: "# Hello".to_string(),
493        };
494        let json = serde_json::to_string(&file).unwrap();
495        let restored: SyncFile = serde_json::from_str(&json).unwrap();
496        assert_eq!(restored.path, "notes/Test.md");
497        assert_eq!(restored.content, "# Hello");
498    }
499
500    #[test]
501    fn test_sync_request_serialization() {
502        let req = SyncRequest {
503            modified: vec![],
504            deleted: vec!["old.md".to_string()],
505            timestamps: {
506                let mut m = HashMap::new();
507                m.insert("/".to_string(), 1700000000000);
508                m
509            },
510        };
511        let json = serde_json::to_string(&req).unwrap();
512        let restored: SyncRequest = serde_json::from_str(&json).unwrap();
513        assert!(restored.modified.is_empty());
514        assert_eq!(restored.deleted.len(), 1);
515        assert_eq!(restored.deleted[0], "old.md");
516    }
517
518    #[test]
519    fn test_sync_error_from_fs_error() {
520        let err = SyncError::from(FsError::QuotaExceeded);
521        assert!(matches!(err, SyncError::QuotaExceeded));
522
523        let err = SyncError::from(FsError::CannotUnhash);
524        assert!(matches!(err, SyncError::Storage(_)));
525    }
526
527    #[test]
528    fn test_sync_error_display() {
529        assert_eq!(SyncError::InvalidJson.to_string(), "invalid JSON");
530        assert_eq!(SyncError::NotFound.to_string(), "file not found");
531        assert_eq!(SyncError::QuotaExceeded.to_string(), "quota exceeded");
532    }
533
534    #[test]
535    fn test_knowledge_config_default() {
536        let config = KnowledgeConfig::default();
537        assert_eq!(config.language, "en");
538        assert_eq!(config.timezone, "UTC");
539        assert_eq!(config.mode, "full");
540        assert_eq!(config.pomodoro_duration_in_minutes, 50);
541        assert!(config.move_to_commands.is_empty());
542        assert!(config.schedules.is_empty());
543        assert!(config.quick_commands.is_empty());
544        assert!(!config.two_emojis_enabled);
545        assert!(!config.quick_habits_enabled);
546        assert!(config.channels.is_empty());
547    }
548
549    #[test]
550    fn test_knowledge_config_serialization_roundtrip() {
551        let config = KnowledgeConfig {
552            language: "ko".to_string(),
553            timezone: "+09:00".to_string(),
554            move_to_commands: vec!["archive".to_string()],
555            pomodoro_duration_in_minutes: 25,
556            schedules: vec![Schedule {
557                filename: "Daily.md".to_string(),
558                scheduled_at: 1700000000000,
559                cron: "9:00".to_string(),
560                cmd: String::new(),
561            }],
562            quick_commands: vec!["today".to_string()],
563            two_emojis_enabled: true,
564            mode: "chat".to_string(),
565            quick_habits_enabled: true,
566            channels: vec![42],
567        };
568        let json = serde_json::to_string(&config).unwrap();
569        let restored: KnowledgeConfig = serde_json::from_str(&json).unwrap();
570        assert_eq!(restored.language, "ko");
571        assert_eq!(restored.timezone, "+09:00");
572        assert_eq!(restored.mode, "chat");
573        assert_eq!(restored.pomodoro_duration_in_minutes, 25);
574        assert_eq!(restored.schedules.len(), 1);
575        assert_eq!(restored.schedules[0].cron, "9:00");
576        assert!(restored.two_emojis_enabled);
577        assert!(restored.quick_habits_enabled);
578        assert_eq!(restored.channels, vec![42]);
579    }
580
581    #[test]
582    fn test_constants() {
583        assert_eq!(MAX_TEXT_SIZE, 5 * 1024 * 1024);
584        assert_eq!(MAX_TEXTS_SIZE, 10 * 1024 * 1024);
585        assert_eq!(MAX_MEDIA_SIZE, 20 * 1024 * 1024);
586        assert_eq!(MAX_MEDIAS_SIZE, 512 * 1024);
587        assert_eq!(MAX_TOKEN_SIZE, 4 * 1024);
588        assert_eq!(CHAT_FILENAME, "Chat.md");
589        assert_eq!(DIR_ARCHIVE, "archive");
590        assert_eq!(DIR_JOURNAL, "journal");
591        assert_eq!(DIR_HABITS, "habits");
592        assert_eq!(MD_EXT, ".md");
593        assert_eq!(HABIT_SKIPPED, "\u{26aa}\u{fe0f}");
594        assert_eq!(HABIT_COMPLETED, "\u{1f7e2}");
595        assert_eq!(MODE_CHAT, "chat");
596        assert_eq!(MODE_FULL, "full");
597        assert_eq!(MOOD_HABIT, "Mood");
598        assert_eq!(MOOD_EMOJIS.len(), 6);
599    }
600}