1use std::collections::BTreeMap;
20use std::path::PathBuf;
21use std::sync::Mutex;
22
23use anyhow::{bail, Context, Result};
24use packset_client::PacksetClient;
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28pub const ORIGIN_USER: &str = "user-declared";
29pub const ORIGIN_AGENT: &str = "agent-derived";
30pub const ORIGIN_PEER: &str = "peer";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Filed {
34 pub id: String,
35 pub remote: bool,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39struct Record {
40 id: String,
41 status: String,
42 remote: bool,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 remote_id: Option<String>,
45 text: String,
46 kind: String,
47 atom: Value,
48}
49
50fn gate() -> std::sync::MutexGuard<'static, ()> {
51 static GATE: Mutex<()> = Mutex::new(());
52 GATE.lock().unwrap_or_else(|e| e.into_inner())
53}
54
55fn origin_cache() -> &'static Mutex<BTreeMap<String, bool>> {
56 static KEPT: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
57 KEPT.get_or_init(|| Mutex::new(BTreeMap::new()))
58}
59
60fn proposal_cache() -> &'static Mutex<BTreeMap<String, bool>> {
61 static MODE: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
62 MODE.get_or_init(|| Mutex::new(BTreeMap::new()))
63}
64
65fn proposals_path() -> PathBuf {
66 std::env::var_os("XDG_STATE_HOME")
67 .map(PathBuf::from)
68 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
69 .unwrap_or_else(|| PathBuf::from(".local/state"))
70 .join("ljos")
71 .join("proposals.jsonl")
72}
73
74fn http_timeout() -> std::time::Duration {
75 std::env::var("PACKSET_TIMEOUT_MS")
76 .ok()
77 .and_then(|v| v.trim().parse::<u64>().ok())
78 .filter(|ms| *ms > 0)
79 .map_or(
80 std::time::Duration::from_secs(30),
81 std::time::Duration::from_millis,
82 )
83}
84
85pub fn stamp_origin(atom: &mut Value, origin: &str) {
86 if let Some(map) = atom.as_object_mut() {
87 map.insert("origin".into(), Value::String(origin.to_string()));
88 }
89}
90
91fn strip_origin(atom: &mut Value) {
92 if let Some(map) = atom.as_object_mut() {
93 map.remove("origin");
94 }
95}
96
97fn origin_rejected(text: &str) -> bool {
98 let lower = text.to_lowercase();
99 lower.contains("origin")
100 && (lower.contains("unknown")
101 || lower.contains("unexpected")
102 || lower.contains("not a field")
103 || lower.contains("unrecognized"))
104}
105
106pub fn post_kept(client: &PacksetClient, atom: &Value) -> Result<Value> {
109 let base = client.base().to_string();
110 let mut body = atom.clone();
111 let known = origin_cache()
112 .lock()
113 .unwrap_or_else(|e| e.into_inner())
114 .get(&base)
115 .copied();
116 if known == Some(false) {
117 strip_origin(&mut body);
118 }
119 match client.post_atom(&body) {
120 Ok(v) => {
121 if body.get("origin").is_some() {
122 origin_cache()
123 .lock()
124 .unwrap_or_else(|e| e.into_inner())
125 .insert(base, true);
126 }
127 Ok(v)
128 }
129 Err(e) => {
130 let text = e.to_string();
131 if body.get("origin").is_some() && origin_rejected(&text) {
132 origin_cache()
133 .lock()
134 .unwrap_or_else(|err| err.into_inner())
135 .insert(base, false);
136 strip_origin(&mut body);
137 return client.post_atom(&body).map_err(anyhow::Error::from);
138 }
139 Err(anyhow::Error::from(e))
140 }
141 }
142}
143
144fn read_latest() -> BTreeMap<String, Record> {
145 let text = std::fs::read_to_string(proposals_path()).unwrap_or_default();
146 let mut by_id = BTreeMap::new();
147 for line in text.lines() {
148 if let Ok(rec) = serde_json::from_str::<Record>(line) {
149 by_id.insert(rec.id.clone(), rec);
150 }
151 }
152 by_id
153}
154
155fn append(rec: &Record) -> Result<()> {
156 let path = proposals_path();
157 if let Some(dir) = path.parent() {
158 std::fs::create_dir_all(dir)?;
159 }
160 let mut line = serde_json::to_string(rec)?;
161 line.push('\n');
162 use std::io::Write;
163 let mut file = std::fs::OpenOptions::new()
164 .create(true)
165 .append(true)
166 .open(path)?;
167 file.write_all(line.as_bytes())?;
168 Ok(())
169}
170
171fn proposal_is_local(base: &str) -> bool {
172 proposal_cache()
173 .lock()
174 .unwrap_or_else(|e| e.into_inner())
175 .get(base)
176 .copied()
177 == Some(false)
178}
179
180fn remember_proposal_mode(base: &str, remote: bool) {
181 proposal_cache()
182 .lock()
183 .unwrap_or_else(|e| e.into_inner())
184 .insert(base.to_string(), remote);
185}
186
187fn held_proposal_id(text: &str) -> Option<String> {
189 let marker = "held as proposal ";
190 let rest = text.get(text.find(marker)? + marker.len()..)?;
191 let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
192 (id.len() == 32).then_some(id)
193}
194
195fn filing_is_local(text: &str) -> bool {
197 let lower = text.to_lowercase();
198 origin_rejected(text)
199 || lower.contains(": 404:")
200 || lower.contains("status code 404")
201 || (lower.contains("403") && lower.contains("not allowed"))
202}
203
204fn post_direct(client: &PacksetClient, atom: &Value) -> Result<Option<String>> {
206 let base = client.base();
207 if proposal_is_local(base) {
208 return Ok(None);
209 }
210 match client.post_atom(atom) {
211 Ok(_) => {
212 remember_proposal_mode(base, false);
214 Ok(None)
215 }
216 Err(e) => {
217 let text = e.to_string();
218 if let Some(id) = held_proposal_id(&text) {
219 remember_proposal_mode(base, true);
220 return Ok(Some(id));
221 }
222 if filing_is_local(&text) {
223 remember_proposal_mode(base, false);
224 return Ok(None);
225 }
226 if !text.contains("bad response") {
229 return Ok(None);
230 }
231 bail!("propose: POST /v1/atoms failed: {text}");
232 }
233 }
234}
235
236pub fn propose_atom(client: &PacksetClient, mut atom: Value) -> Result<Filed> {
242 let _g = gate();
243 let text = atom["text"].as_str().unwrap_or("").trim().to_string();
244 let kind = atom["kind"].as_str().unwrap_or("lesson").to_string();
245 if text.is_empty() {
246 bail!("propose: empty text is not a proposal");
247 }
248 stamp_origin(&mut atom, ORIGIN_AGENT);
249 let id = crate::work_id(&format!("{kind}\n{text}"));
250 if let Some(existing) = read_latest().get(&id) {
251 if matches!(existing.status.as_str(), "open" | "accepted" | "satisfied") {
252 return Ok(Filed {
253 id,
254 remote: existing.remote,
255 });
256 }
257 }
258 let remote_id = post_direct(client, &atom)?;
259 let rec = Record {
260 id: id.clone(),
261 status: "open".into(),
262 remote: remote_id.is_some(),
263 remote_id,
264 text,
265 kind,
266 atom,
267 };
268 let remote = rec.remote;
269 append(&rec)?;
270 Ok(Filed { id, remote })
271}
272
273pub fn satisfy_text(text: &str) {
276 let _g = gate();
277 let text = text.trim();
278 if text.is_empty() {
279 return;
280 }
281 let open: Vec<Record> = read_latest()
282 .into_values()
283 .filter(|rec| rec.status == "open" && rec.text.trim() == text)
284 .collect();
285 for mut rec in open {
286 rec.status = "satisfied".into();
287 let _ = append(&rec);
288 close_remote(&rec);
289 }
290}
291
292fn workspace_of(client: &PacksetClient, rec: &Record) -> String {
293 let workspace = rec
294 .atom
295 .get("workspace")
296 .and_then(Value::as_str)
297 .unwrap_or("");
298 if workspace.is_empty() {
299 client.workspace()
300 } else {
301 workspace.to_string()
302 }
303}
304
305fn close_remote(rec: &Record) {
308 if !rec.remote {
309 return;
310 }
311 let Some(remote_id) = rec.remote_id.as_deref() else {
312 return;
313 };
314 let Ok(client) = crate::pack() else {
315 return;
316 };
317 let workspace = workspace_of(&client, rec);
318 let _ = accept_on_writer(&client, &workspace, remote_id);
319}
320
321fn accepted_line(id: &str, kind: &str, posted: &Value) -> String {
322 let origin = posted
323 .get("origin")
324 .and_then(Value::as_str)
325 .filter(|origin| !origin.is_empty())
326 .unwrap_or(ORIGIN_AGENT);
327 format!(
328 "accepted {id} as {kind} origin {origin}{}\n",
329 crate::revision_note(posted)
330 )
331}
332
333fn accept_on_writer(client: &PacksetClient, workspace: &str, id: &str) -> Result<Value> {
334 let url = format!("{}/v1/proposals/accept", client.base());
335 let body = serde_json::json!({"workspace": workspace, "id": id});
336 match ureq::post(&url).timeout(http_timeout()).send_json(body) {
337 Ok(resp) => resp
338 .into_json()
339 .context("accept: the pack's answer was not json"),
340 Err(ureq::Error::Status(code, resp)) => {
341 let text = resp.into_string().unwrap_or_default();
342 bail!("accept: POST /v1/proposals/accept failed: {code}: {text}");
343 }
344 Err(e) => bail!("accept: POST /v1/proposals/accept failed: {e}"),
345 }
346}
347
348pub fn accept(id: &str) -> Result<String> {
351 let _g = gate();
352 let id = id.trim();
353 if id.is_empty() {
354 bail!("accept: a proposal id is required");
355 }
356 let Some(mut rec) = read_latest().get(id).cloned() else {
357 bail!("accept: no open proposal {id}");
358 };
359 match rec.status.as_str() {
360 "satisfied" => {
361 close_remote(&rec);
362 return Ok(format!("{id} was already written by remember or prefer\n"));
363 }
364 "accepted" => return Ok(format!("{id} is already in the pack\n")),
365 "open" => {}
366 _ => bail!("accept: no open proposal {id}"),
367 }
368 let client = crate::pack()?;
369 if rec.remote {
370 let remote_id = rec.remote_id.clone().unwrap_or_else(|| rec.id.clone());
371 let workspace = workspace_of(&client, &rec);
372 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
373 rec.status = "accepted".into();
374 append(&rec)?;
375 return Ok(accepted_line(&rec.id, &rec.kind, &posted));
376 }
377 match post_kept(&client, &rec.atom) {
378 Ok(posted) => {
379 rec.status = "accepted".into();
380 append(&rec)?;
381 Ok(accepted_line(&rec.id, &rec.kind, &posted))
382 }
383 Err(e) => {
384 let text = e.to_string();
385 let Some(remote_id) = held_proposal_id(&text) else {
386 return Err(e).with_context(|| format!("accept: POST /v1/atoms failed for {id}"));
387 };
388 let workspace = workspace_of(&client, &rec);
391 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
392 rec.remote = true;
393 rec.remote_id = Some(remote_id);
394 rec.status = "accepted".into();
395 append(&rec)?;
396 Ok(accepted_line(&rec.id, &rec.kind, &posted))
397 }
398 }
399}