Skip to main content

pwr_core/
metadata.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Represents the state of a tracked project.
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum ProjectState {
9    /// Project files are present on local disk.
10    Local,
11    /// Project is stored on the server; local directory is a placeholder.
12    Archived,
13}
14
15/// Core metadata for a tracked project.
16///
17/// Serialized as `.project.toml` inside the project directory.
18/// This file persists locally regardless of whether the project
19/// content is present (Local state) or stored on the server
20/// (Archived state), providing stable identity and reconnection
21/// information.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ProjectMeta {
24    /// Schema version for forward compatibility.
25    pub version: u32,
26
27    /// Unique identifier for this project, generated at creation time.
28    /// Survives renames and moves.
29    pub uuid: Uuid,
30
31    /// Human-readable project name, derived from the directory name
32    /// at the time of first archive.
33    pub name: String,
34
35    /// Absolute path to the project directory on the local machine.
36    pub local_path: String,
37
38    /// Server-side storage reference in the format
39    /// "host:port:/base/path/project-name".
40    pub remote_path: String,
41
42    /// Total size in bytes at the time of the last successful sync.
43    pub size_bytes: u64,
44
45    /// Number of files in the project at the time of the last sync.
46    #[serde(default)]
47    pub file_count: u32,
48
49    /// Timestamp of the last successful sync (upload or download).
50    pub last_sync: DateTime<Utc>,
51
52    /// Whether compression was used during the last transfer.
53    pub compression: bool,
54
55    /// Whether at-rest encryption was enabled for this project.
56    #[serde(default)]
57    pub encryption_enabled: bool,
58
59    /// The age public key used for encrypting this project's archive,
60    /// stored as a Bech32-encoded string. Only set if encryption is
61    /// enabled.
62    #[serde(default)]
63    pub public_key: Option<String>,
64
65    /// Current state of the project.
66    pub state: ProjectState,
67}
68
69impl ProjectMeta {
70    /// Create metadata for a new project that is currently local.
71    ///
72    /// Generates a fresh UUIDv4 and sets the last_sync timestamp
73    /// to the current time. The project starts in the Local state
74    /// with zero size and no compression or encryption.
75    pub fn new_local(name: String, local_path: String, remote_path: String) -> Self {
76        Self {
77            version: 1,
78            uuid: Uuid::new_v4(),
79            name,
80            local_path,
81            remote_path,
82            size_bytes: 0,
83            file_count: 0,
84            last_sync: Utc::now(),
85            compression: false,
86            encryption_enabled: false,
87            public_key: None,
88            state: ProjectState::Local,
89        }
90    }
91
92    /// Create metadata with encryption enabled.
93    ///
94    /// Generates a fresh UUIDv4 and stores the age public key
95    /// that will be used to encrypt the project archive before
96    /// uploading to the server.
97    pub fn new_local_encrypted(
98        name: String,
99        local_path: String,
100        remote_path: String,
101        public_key: String,
102    ) -> Self {
103        Self {
104            version: 1,
105            uuid: Uuid::new_v4(),
106            name,
107            local_path,
108            remote_path,
109            size_bytes: 0,
110            file_count: 0,
111            last_sync: Utc::now(),
112            compression: false,
113            encryption_enabled: true,
114            public_key: Some(public_key),
115            state: ProjectState::Local,
116        }
117    }
118
119    /// Mark the project as archived after a successful upload to the server.
120    ///
121    /// Updates the state, records the transferred size and file count,
122    /// and sets the last_sync timestamp to now.
123    pub fn mark_archived(&mut self, size_bytes: u64, file_count: u32, compression: bool) {
124        self.state = ProjectState::Archived;
125        self.size_bytes = size_bytes;
126        self.file_count = file_count;
127        self.compression = compression;
128        self.last_sync = Utc::now();
129    }
130
131    /// Mark the project as local after a successful download from the server.
132    ///
133    /// Updates the state, records the restored size, and sets the
134    /// last_sync timestamp to now.
135    pub fn mark_local(&mut self, size_bytes: u64, file_count: u32) {
136        self.state = ProjectState::Local;
137        self.size_bytes = size_bytes;
138        self.file_count = file_count;
139        self.last_sync = Utc::now();
140    }
141
142    /// Returns `true` if the project is currently archived (stored on
143    /// the server, local directory is a placeholder).
144    pub fn is_archived(&self) -> bool {
145        self.state == ProjectState::Archived
146    }
147
148    /// Returns `true` if the project is currently local.
149    pub fn is_local(&self) -> bool {
150        self.state == ProjectState::Local
151    }
152
153    /// Return the number of days since the last sync.
154    ///
155    /// Useful for identifying stale projects that might be candidates
156    /// for automatic archival.
157    pub fn days_since_sync(&self) -> f64 {
158        let duration = Utc::now() - self.last_sync;
159        duration.num_milliseconds() as f64 / (1000.0 * 60.0 * 60.0 * 24.0)
160    }
161
162    /// Format the project size in a human-readable way (e.g., "1.5 GB").
163    pub fn size_human(&self) -> String {
164        human_size(self.size_bytes)
165    }
166}
167
168/// Format a byte count as a human-readable string.
169///
170/// Uses binary prefixes (1024-based): B, KB, MB, GB, TB.
171/// Values below 1024 are displayed as plain bytes.
172pub fn human_size(bytes: u64) -> String {
173    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
174    let mut size = bytes as f64;
175    let mut unit_idx = 0;
176    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
177        size /= 1024.0;
178        unit_idx += 1;
179    }
180    if unit_idx == 0 {
181        format!("{} B", bytes)
182    } else {
183        format!("{:.1} {}", size, UNITS[unit_idx])
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_human_size() {
193        assert_eq!(human_size(0), "0 B");
194        assert_eq!(human_size(1023), "1023 B");
195        assert_eq!(human_size(1024), "1.0 KB");
196        assert_eq!(human_size(1_048_576), "1.0 MB");
197        assert_eq!(human_size(1_073_741_824), "1.0 GB");
198        assert_eq!(human_size(2_199_023_255_552), "2.0 TB");
199    }
200
201    #[test]
202    fn test_project_state_serialization() {
203        let meta = ProjectMeta::new_local(
204            "testproj".into(),
205            "/home/jacob/Projects/testproj".into(),
206            "nas.local:9742:/srv/pwr/projects/testproj".into(),
207        );
208        assert_eq!(meta.state, ProjectState::Local);
209        assert_eq!(meta.version, 1);
210        assert!(!meta.is_archived());
211        assert!(meta.is_local());
212    }
213
214    #[test]
215    fn test_state_transitions() {
216        let mut meta = ProjectMeta::new_local(
217            "testproj".into(),
218            "/home/jacob/Projects/testproj".into(),
219            "nas.local:9742:/srv/pwr/projects/testproj".into(),
220        );
221        assert!(meta.is_local());
222
223        // Archive
224        meta.mark_archived(1_048_576, 42, true);
225        assert!(meta.is_archived());
226        assert_eq!(meta.size_bytes, 1_048_576);
227        assert_eq!(meta.file_count, 42);
228        assert!(meta.compression);
229
230        // Restore
231        meta.mark_local(1_048_576, 42);
232        assert!(meta.is_local());
233        assert!(!meta.is_archived());
234    }
235
236    #[test]
237    fn test_encrypted_project() {
238        let meta = ProjectMeta::new_local_encrypted(
239            "secretproj".into(),
240            "/home/jacob/Projects/secretproj".into(),
241            "nas.local:9742:/srv/pwr/projects/secretproj".into(),
242            "age1qx0...".into(),
243        );
244        assert!(meta.encryption_enabled);
245        assert!(meta.public_key.is_some());
246    }
247
248    #[test]
249    fn test_days_since_sync() {
250        let meta = ProjectMeta::new_local(
251            "recent".into(),
252            "/tmp/recent".into(),
253            "nas.local:9742:/srv/pwr/projects/recent".into(),
254        );
255        // Just created, should be nearly zero
256        assert!(meta.days_since_sync() < 0.01);
257    }
258
259    #[test]
260    fn test_serialization_round_trip() {
261        let meta = ProjectMeta::new_local_encrypted(
262            "roundtrip".into(),
263            "/tmp/roundtrip".into(),
264            "server:9742:/srv/roundtrip".into(),
265            "age1test".into(),
266        );
267
268        let toml_str = toml::to_string_pretty(&meta).unwrap();
269        let parsed: ProjectMeta = toml::from_str(&toml_str).unwrap();
270
271        assert_eq!(parsed.uuid, meta.uuid);
272        assert_eq!(parsed.name, "roundtrip");
273        assert_eq!(parsed.encryption_enabled, true);
274        assert_eq!(parsed.public_key, Some("age1test".into()));
275        assert_eq!(parsed.state, ProjectState::Local);
276    }
277}