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// Knowledge Config Types
284// ============================================================================
285
286/// User knowledge base configuration.
287///
288/// Stored as `config.json` in the knowledge base root.
289/// Decoupled from any server-specific config.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct KnowledgeConfig {
292    /// Language code (e.g., "en", "ko").
293    #[serde(default = "default_language")]
294    pub language: String,
295    /// Timezone string (e.g., "+09:00", "UTC").
296    #[serde(default = "default_timezone")]
297    pub timezone: String,
298    /// Move-to commands (quick file organization).
299    #[serde(default)]
300    pub move_to_commands: Vec<String>,
301    /// Pomodoro timer duration in minutes.
302    #[serde(default = "default_pomodoro_duration")]
303    pub pomodoro_duration_in_minutes: i64,
304    /// Scheduled tasks.
305    #[serde(default)]
306    pub schedules: Vec<Schedule>,
307    /// Quick commands.
308    #[serde(default)]
309    pub quick_commands: Vec<String>,
310    /// Whether to show two emojis per button.
311    #[serde(default)]
312    pub two_emojis_enabled: bool,
313    /// Mode: "chat", "full", "tasks", "notes", "journal".
314    #[serde(default = "default_mode")]
315    pub mode: String,
316    /// Whether quick habits are enabled.
317    #[serde(default)]
318    pub quick_habits_enabled: bool,
319    /// Associated channel IDs.
320    #[serde(default)]
321    pub channels: Vec<i64>,
322}
323
324fn default_language() -> String {
325    "en".to_string()
326}
327fn default_timezone() -> String {
328    "UTC".to_string()
329}
330fn default_pomodoro_duration() -> i64 {
331    50
332}
333fn default_mode() -> String {
334    "full".to_string()
335}
336
337impl Default for KnowledgeConfig {
338    fn default() -> Self {
339        Self {
340            language: default_language(),
341            timezone: default_timezone(),
342            move_to_commands: vec![],
343            pomodoro_duration_in_minutes: default_pomodoro_duration(),
344            schedules: vec![],
345            quick_commands: vec![],
346            two_emojis_enabled: false,
347            mode: default_mode(),
348            quick_habits_enabled: false,
349            channels: vec![],
350        }
351    }
352}
353
354/// Chat/Inbox mode constants.
355pub const MODE_CHAT: &str = "chat";
356/// Full mode constant.
357pub const MODE_FULL: &str = "full";
358/// Tasks-only mode constant.
359pub const MODE_TASKS: &str = "tasks";
360/// Notes-only mode constant.
361pub const MODE_NOTES: &str = "notes";
362/// Journal-only mode constant.
363pub const MODE_JOURNAL: &str = "journal";
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_file_entry_new() {
371        let entry = FileEntry::new(
372            "Rust.md".to_string(),
373            "abc12345678".to_string(),
374            "Rust".to_string(),
375            1700000000000,
376            true,
377            false,
378            "/notes".to_string(),
379        );
380        assert_eq!(entry.name, "Rust.md");
381        assert_eq!(entry.hash, "abc12345678");
382        assert_eq!(entry.display_name, "Rust");
383        assert!(entry.has_content);
384        assert!(!entry.is_dir);
385        assert_eq!(entry.parent_dir, "/notes");
386    }
387
388    #[test]
389    fn test_file_entry_serialization() {
390        let entry = FileEntry::new(
391            "Test.md".to_string(),
392            "hash".to_string(),
393            "Test".to_string(),
394            1000,
395            false,
396            true,
397            "/".to_string(),
398        );
399        let json = serde_json::to_string(&entry).unwrap();
400        let restored: FileEntry = serde_json::from_str(&json).unwrap();
401        assert_eq!(restored.name, entry.name);
402        assert!(restored.is_dir);
403        assert!(!restored.has_content);
404    }
405
406    #[test]
407    fn test_fs_error_display() {
408        assert_eq!(FsError::QuotaExceeded.to_string(), "storage quota exceeded");
409        assert_eq!(
410            FsError::UnsafePath.to_string(),
411            "unsafe path, possible security issue"
412        );
413        assert_eq!(
414            FsError::CannotUnhash.to_string(),
415            "cannot unhash, maybe the file is missing"
416        );
417    }
418
419    #[test]
420    fn test_sync_response_default() {
421        let resp = SyncResponse::default();
422        assert_eq!(resp.status, STATUS_OK);
423        assert!(resp.files.is_empty());
424        assert!(resp.timestamps.is_empty());
425        assert!(resp.renames.is_empty());
426    }
427
428    #[test]
429    fn test_sync_file_serialization() {
430        let file = SyncFile {
431            status: STATUS_OK.to_string(),
432            path: "notes/Test.md".to_string(),
433            last_modified: 1700000000000,
434            client_last_modified: 1700000000000,
435            client_last_synced: 1700000000000,
436            content: "# Hello".to_string(),
437        };
438        let json = serde_json::to_string(&file).unwrap();
439        let restored: SyncFile = serde_json::from_str(&json).unwrap();
440        assert_eq!(restored.path, "notes/Test.md");
441        assert_eq!(restored.content, "# Hello");
442    }
443
444    #[test]
445    fn test_sync_request_serialization() {
446        let req = SyncRequest {
447            modified: vec![],
448            deleted: vec!["old.md".to_string()],
449            timestamps: {
450                let mut m = HashMap::new();
451                m.insert("/".to_string(), 1700000000000);
452                m
453            },
454        };
455        let json = serde_json::to_string(&req).unwrap();
456        let restored: SyncRequest = serde_json::from_str(&json).unwrap();
457        assert!(restored.modified.is_empty());
458        assert_eq!(restored.deleted.len(), 1);
459        assert_eq!(restored.deleted[0], "old.md");
460    }
461
462    #[test]
463    fn test_sync_error_from_fs_error() {
464        let err = SyncError::from(FsError::QuotaExceeded);
465        assert!(matches!(err, SyncError::QuotaExceeded));
466
467        let err = SyncError::from(FsError::CannotUnhash);
468        assert!(matches!(err, SyncError::Storage(_)));
469    }
470
471    #[test]
472    fn test_sync_error_display() {
473        assert_eq!(SyncError::InvalidJson.to_string(), "invalid JSON");
474        assert_eq!(SyncError::NotFound.to_string(), "file not found");
475        assert_eq!(SyncError::QuotaExceeded.to_string(), "quota exceeded");
476    }
477
478    #[test]
479    fn test_knowledge_config_default() {
480        let config = KnowledgeConfig::default();
481        assert_eq!(config.language, "en");
482        assert_eq!(config.timezone, "UTC");
483        assert_eq!(config.mode, "full");
484        assert_eq!(config.pomodoro_duration_in_minutes, 50);
485        assert!(config.move_to_commands.is_empty());
486        assert!(config.schedules.is_empty());
487        assert!(config.quick_commands.is_empty());
488        assert!(!config.two_emojis_enabled);
489        assert!(!config.quick_habits_enabled);
490        assert!(config.channels.is_empty());
491    }
492
493    #[test]
494    fn test_knowledge_config_serialization_roundtrip() {
495        let config = KnowledgeConfig {
496            language: "ko".to_string(),
497            timezone: "+09:00".to_string(),
498            move_to_commands: vec!["archive".to_string()],
499            pomodoro_duration_in_minutes: 25,
500            schedules: vec![Schedule {
501                filename: "Daily.md".to_string(),
502                scheduled_at: 1700000000000,
503                cron: "9:00".to_string(),
504                cmd: String::new(),
505            }],
506            quick_commands: vec!["today".to_string()],
507            two_emojis_enabled: true,
508            mode: "chat".to_string(),
509            quick_habits_enabled: true,
510            channels: vec![42],
511        };
512        let json = serde_json::to_string(&config).unwrap();
513        let restored: KnowledgeConfig = serde_json::from_str(&json).unwrap();
514        assert_eq!(restored.language, "ko");
515        assert_eq!(restored.timezone, "+09:00");
516        assert_eq!(restored.mode, "chat");
517        assert_eq!(restored.pomodoro_duration_in_minutes, 25);
518        assert_eq!(restored.schedules.len(), 1);
519        assert_eq!(restored.schedules[0].cron, "9:00");
520        assert!(restored.two_emojis_enabled);
521        assert!(restored.quick_habits_enabled);
522        assert_eq!(restored.channels, vec![42]);
523    }
524
525    #[test]
526    fn test_constants() {
527        assert_eq!(MAX_TEXT_SIZE, 5 * 1024 * 1024);
528        assert_eq!(MAX_TEXTS_SIZE, 10 * 1024 * 1024);
529        assert_eq!(MAX_MEDIA_SIZE, 20 * 1024 * 1024);
530        assert_eq!(MAX_MEDIAS_SIZE, 512 * 1024);
531        assert_eq!(MAX_TOKEN_SIZE, 4 * 1024);
532        assert_eq!(CHAT_FILENAME, "Chat.md");
533        assert_eq!(DIR_ARCHIVE, "archive");
534        assert_eq!(DIR_JOURNAL, "journal");
535        assert_eq!(DIR_HABITS, "habits");
536        assert_eq!(MD_EXT, ".md");
537        assert_eq!(HABIT_SKIPPED, "\u{26aa}\u{fe0f}");
538        assert_eq!(HABIT_COMPLETED, "\u{1f7e2}");
539        assert_eq!(MODE_CHAT, "chat");
540        assert_eq!(MODE_FULL, "full");
541        assert_eq!(MOOD_HABIT, "Mood");
542        assert_eq!(MOOD_EMOJIS.len(), 6);
543    }
544}