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