Skip to main content

sioul_core/
textdraft.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A text drafted by an AI agent (`sioul mcp`, `draft_text`; docs/mcp.md,
5//! "Texts"): it waits in the Texts page, in its conversation, until you use
6//! it (its words go in the box, and sending stays your Send) or discard it.
7//! **Never sent**: a draft is no request (`texts::Request`), and nothing
8//! reads this file to send.
9//!
10//! Kept on this device only, never shared: `drafts.jsonl` in the texts'
11//! private folder (`texts::private_folder`), each line sealed as the texts'
12//! own lines are (`texts::Sealer`), so that a draft is unreadable without the
13//! key, as the texts it answers are.
14
15use crate::texts::{self, Sealer};
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18
19/// The file's name in the texts' private folder.
20pub const FILE: &str = "drafts.jsonl";
21
22/// One draft waiting.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct TextDraft {
25    /// Random, 32 hex digits (`texts::new_key`).
26    pub id: String,
27    /// The number, as written.
28    pub to: String,
29    /// Its conversation (`texts::conversation_id` of the number's key), where the page shows it.
30    pub conversation: String,
31    pub body: String,
32    /// When it was written (ms).
33    pub written: i64,
34    /// Who wrote it: the agent's name, as its client gave it.
35    #[serde(default, skip_serializing_if = "String::is_empty")]
36    pub by: String,
37}
38
39/// Where the drafts are kept.
40pub fn path() -> PathBuf {
41    texts::private_folder().join(FILE)
42}
43
44/// Every draft of the file that opens, oldest first.
45pub fn all_in(file: &Path, sealer: &dyn Sealer) -> Vec<TextDraft> {
46    let mut drafts: Vec<TextDraft> = texts::read_file(file, sealer);
47    drafts.sort_by_key(|d| d.written);
48    drafts
49}
50
51pub fn all(sealer: &dyn Sealer) -> Vec<TextDraft> {
52    all_in(&path(), sealer)
53}
54
55/// A draft added, its words sealed; never over another (a new id).
56pub fn add_in(file: &Path, draft: &TextDraft, sealer: &dyn Sealer) -> Result<(), String> {
57    texts::append(file, std::slice::from_ref(draft), sealer, Some(&|d: &TextDraft| d.id.clone())).map(|_| ())
58}
59
60pub fn add(draft: &TextDraft, sealer: &dyn Sealer) -> Result<(), String> {
61    add_in(&path(), draft, sealer)
62}
63
64/// A draft taken out (used, or discarded): the file written again without
65/// it, under its lock, private, beside then put in place. Lines that do not
66/// open stay as they were. Whether it was there.
67pub fn remove_in(file: &Path, id: &str, sealer: &dyn Sealer) -> Result<bool, String> {
68    let gone = crate::calls::rewrite_lines(file, |line| sealer.open(line.trim()).and_then(|plain| serde_json::from_str::<TextDraft>(&plain).ok()).is_some_and(|d| d.id == id))?;
69    Ok(gone > 0)
70}
71
72pub fn remove(id: &str, sealer: &dyn Sealer) -> Result<bool, String> {
73    remove_in(&path(), id, sealer)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    /// A sealer for tests: reversible, and visibly not plain text.
81    struct Rot;
82    impl Sealer for Rot {
83        fn seal(&self, plain: &str) -> String {
84            format!("rot:{}", plain.chars().rev().collect::<String>())
85        }
86        fn open(&self, sealed: &str) -> Option<String> {
87            sealed.strip_prefix("rot:").map(|s| s.chars().rev().collect())
88        }
89    }
90
91    #[test]
92    fn drafts_wait_sealed_until_used_or_discarded() {
93        let dir = std::env::temp_dir().join(format!("sioul-textdraft-{}", std::process::id()));
94        let _ = std::fs::remove_dir_all(&dir);
95        let file = dir.join(FILE);
96        let one = TextDraft { id: "a".repeat(32), to: "01 99 00 12 34".into(), conversation: "+33199001234".into(), body: "See you at six.".into(), written: 2, by: "claude-code".into() };
97        let two = TextDraft { id: "b".repeat(32), written: 1, ..one.clone() };
98        add_in(&file, &one, &Rot).unwrap();
99        add_in(&file, &two, &Rot).unwrap();
100        // The same draft twice is kept once.
101        add_in(&file, &one, &Rot).unwrap();
102        assert!(!std::fs::read_to_string(&file).unwrap().contains("See you at six"), "sealed at rest");
103        assert_eq!(all_in(&file, &Rot).iter().map(|d| d.written).collect::<Vec<_>>(), [1, 2]);
104        assert!(remove_in(&file, &one.id, &Rot).unwrap());
105        assert!(!remove_in(&file, &one.id, &Rot).unwrap());
106        assert_eq!(all_in(&file, &Rot), vec![two]);
107        std::fs::remove_dir_all(&dir).unwrap();
108    }
109}