1use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::PathBuf;
11
12use crate::state::app_meta_dir;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ProjectCollaborator {
16 pub project_id: String,
17 pub project_name: String,
18 pub member_did: String,
19 #[serde(default)]
20 pub display_name: String,
21 pub role: String,
23 pub added_at: u64,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct LocalProject {
29 pub id: String,
30 pub name: String,
31 #[serde(default)]
32 pub description: String,
33 pub created_at: u64,
34 #[serde(default = "default_source_local")]
36 pub source: String,
37}
38
39fn default_source_local() -> String {
40 "local".into()
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ProjectSummary {
46 pub id: String,
47 pub name: String,
48 pub member_count: usize,
49 pub source: String,
50}
51
52fn path() -> PathBuf {
53 app_meta_dir().join("project_collaborators.json")
54}
55
56fn projects_path() -> PathBuf {
57 app_meta_dir().join("coop_projects.json")
58}
59
60fn load() -> Vec<ProjectCollaborator> {
61 fs::read_to_string(path())
62 .ok()
63 .and_then(|t| serde_json::from_str(&t).ok())
64 .unwrap_or_default()
65}
66
67fn save(rows: &[ProjectCollaborator]) -> Result<(), String> {
68 let p = path();
69 if let Some(parent) = p.parent() {
70 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
71 }
72 fs::write(
73 p,
74 serde_json::to_string_pretty(rows).map_err(|e| e.to_string())?,
75 )
76 .map_err(|e| e.to_string())
77}
78
79fn now() -> u64 {
80 std::time::SystemTime::now()
81 .duration_since(std::time::UNIX_EPOCH)
82 .map(|d| d.as_secs())
83 .unwrap_or(0)
84}
85
86pub fn list(project_id: Option<&str>) -> Vec<ProjectCollaborator> {
88 let all = load();
89 match project_id {
90 Some(id) if !id.is_empty() => all.into_iter().filter(|c| c.project_id == id).collect(),
91 _ => all,
92 }
93}
94
95pub fn add(
97 project_id: &str,
98 project_name: &str,
99 member_did: &str,
100 display_name: &str,
101 role: &str,
102) -> Result<ProjectCollaborator, String> {
103 let project_id = project_id.trim();
104 let member_did = member_did.trim();
105 if project_id.is_empty() || member_did.is_empty() {
106 return Err("project_id and member_did are required".into());
107 }
108 let role = match role.trim().to_ascii_lowercase().as_str() {
109 "steward" => "steward",
110 "observer" => "observer",
111 "agent" => "agent",
112 _ => "contributor",
113 };
114 let mut all = load();
115 all.retain(|c| !(c.project_id == project_id && c.member_did == member_did));
116 let row = ProjectCollaborator {
117 project_id: project_id.to_string(),
118 project_name: project_name.trim().to_string(),
119 member_did: member_did.to_string(),
120 display_name: display_name.trim().to_string(),
121 role: role.to_string(),
122 added_at: now(),
123 };
124 all.push(row.clone());
125 save(&all)?;
126 Ok(row)
127}
128
129pub fn remove(project_id: &str, member_did: &str) -> Result<(), String> {
130 let mut all = load();
131 let before = all.len();
132 all.retain(|c| !(c.project_id == project_id && c.member_did == member_did));
133 if all.len() == before {
134 return Err("collaborator not found".into());
135 }
136 save(&all)
137}
138
139fn load_projects() -> Vec<LocalProject> {
140 fs::read_to_string(projects_path())
141 .ok()
142 .and_then(|t| serde_json::from_str(&t).ok())
143 .unwrap_or_default()
144}
145
146fn save_projects(rows: &[LocalProject]) -> Result<(), String> {
147 let p = projects_path();
148 if let Some(parent) = p.parent() {
149 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
150 }
151 fs::write(
152 p,
153 serde_json::to_string_pretty(rows).map_err(|e| e.to_string())?,
154 )
155 .map_err(|e| e.to_string())
156}
157
158pub fn create_local_project(name: &str, description: &str) -> Result<LocalProject, String> {
160 let name = name.trim();
161 if name.is_empty() {
162 return Err("project name is required".into());
163 }
164 let mut all = load_projects();
165 if let Some(existing) = all.iter().find(|p| p.name.eq_ignore_ascii_case(name)) {
166 return Ok(existing.clone());
167 }
168 let id = format!("local-{}", now());
169 let proj = LocalProject {
170 id: id.clone(),
171 name: name.to_string(),
172 description: description.trim().to_string(),
173 created_at: now(),
174 source: "local".into(),
175 };
176 all.push(proj.clone());
177 save_projects(&all)?;
178 let profile = crate::user_profile::load_profile();
180 let self_did = if profile.public_did.is_empty() {
181 crate::user_profile::resolve_public_did(&profile)
182 } else {
183 profile.public_did.clone()
184 };
185 if !self_did.is_empty() {
186 let _ = add(&id, name, &self_did, &profile.display_name, "steward");
187 }
188 Ok(proj)
189}
190
191pub fn register_wellfair_project(
193 id: &str,
194 name: &str,
195 description: &str,
196) -> Result<LocalProject, String> {
197 let id = id.trim();
198 let name = name.trim();
199 if id.is_empty() || name.is_empty() {
200 return Err("id and name required".into());
201 }
202 let mut all = load_projects();
203 all.retain(|p| p.id != id);
204 let proj = LocalProject {
205 id: id.to_string(),
206 name: name.to_string(),
207 description: description.trim().to_string(),
208 created_at: now(),
209 source: "wellfair".into(),
210 };
211 all.push(proj.clone());
212 save_projects(&all)?;
213 Ok(proj)
214}
215
216pub fn list_project_summaries() -> Vec<ProjectSummary> {
218 let mut by_id: BTreeMap<String, ProjectSummary> = BTreeMap::new();
219 for p in load_projects() {
220 let n = list(Some(&p.id)).len();
221 by_id.insert(
222 p.id.clone(),
223 ProjectSummary {
224 id: p.id,
225 name: p.name,
226 member_count: n,
227 source: p.source,
228 },
229 );
230 }
231 for c in load() {
232 by_id
233 .entry(c.project_id.clone())
234 .and_modify(|s| {
235 if s.name.is_empty() && !c.project_name.is_empty() {
236 s.name = c.project_name.clone();
237 }
238 })
240 .or_insert_with(|| ProjectSummary {
241 id: c.project_id.clone(),
242 name: if c.project_name.is_empty() {
243 c.project_id.clone()
244 } else {
245 c.project_name.clone()
246 },
247 member_count: 0,
248 source: "roster".into(),
249 });
250 }
251 for s in by_id.values_mut() {
253 s.member_count = list(Some(&s.id)).len();
254 }
255 let mut out: Vec<_> = by_id.into_values().collect();
256 out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
257 out
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn add_list_remove() {
266 let pid = format!("board-test-{}", now());
267 let did = format!("did:test:{}", now());
268 let row = add(&pid, "Test Project", &did, "Alice", "contributor").expect("add");
269 assert_eq!(row.project_id, pid);
270 assert!(list(Some(&pid)).iter().any(|c| c.member_did == did));
271 remove(&pid, &did).expect("remove");
272 assert!(!list(Some(&pid)).iter().any(|c| c.member_did == did));
273 }
274
275 #[test]
276 fn create_local_project_and_summaries() {
277 let name = format!("CoopTest-{}", now());
278 let p = create_local_project(&name, "desc").expect("create");
279 assert!(!p.id.is_empty());
280 let again = create_local_project(&name, "desc").expect("idempotent");
281 assert_eq!(p.id, again.id);
282 let sums = list_project_summaries();
283 assert!(sums.iter().any(|s| s.name == name));
284 }
285}