1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum ProjectState {
9 Local,
11 Archived,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ProjectMeta {
24 pub version: u32,
26
27 pub uuid: Uuid,
30
31 pub name: String,
34
35 pub local_path: String,
37
38 pub remote_path: String,
41
42 pub size_bytes: u64,
44
45 #[serde(default)]
47 pub file_count: u32,
48
49 pub last_sync: DateTime<Utc>,
51
52 pub compression: bool,
54
55 #[serde(default)]
57 pub encryption_enabled: bool,
58
59 #[serde(default)]
63 pub public_key: Option<String>,
64
65 pub state: ProjectState,
67}
68
69impl ProjectMeta {
70 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 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 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 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 pub fn is_archived(&self) -> bool {
145 self.state == ProjectState::Archived
146 }
147
148 pub fn is_local(&self) -> bool {
150 self.state == ProjectState::Local
151 }
152
153 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 pub fn size_human(&self) -> String {
164 human_size(self.size_bytes)
165 }
166}
167
168pub 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 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 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 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}