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 write_agent(client: &PacksetClient, mut atom: Value) -> Result<Value> {
247 if atom["text"].as_str().unwrap_or("").trim().is_empty() {
248 bail!("remember: empty text is not a claim");
249 }
250 stamp_origin(&mut atom, ORIGIN_AGENT);
251 match post_kept(client, &atom) {
252 Ok(posted) => Ok(posted),
253 Err(e) => {
254 let text = format!("{e:#}");
255 let Some(id) = held_proposal_id(&text) else {
256 return Err(e);
257 };
258 let workspace = atom
259 .get("workspace")
260 .and_then(Value::as_str)
261 .filter(|w| !w.is_empty())
262 .map_or_else(|| client.workspace(), str::to_string);
263 let accepted = accept_on_writer(client, &workspace, &id)?;
264 relabel_agent(client, &workspace, accepted)
268 }
269 }
270}
271
272pub fn propose_atom(client: &PacksetClient, mut atom: Value) -> Result<Filed> {
278 let _g = gate();
279 let text = atom["text"].as_str().unwrap_or("").trim().to_string();
280 let kind = atom["kind"].as_str().unwrap_or("lesson").to_string();
281 if text.is_empty() {
282 bail!("propose: empty text is not a proposal");
283 }
284 stamp_origin(&mut atom, ORIGIN_AGENT);
285 let id = crate::work_id(&format!("{kind}\n{text}"));
286 if let Some(existing) = read_latest().get(&id) {
287 if matches!(existing.status.as_str(), "open" | "accepted" | "satisfied") {
288 return Ok(Filed {
289 id,
290 remote: existing.remote,
291 });
292 }
293 }
294 let remote_id = post_direct(client, &atom)?;
295 let rec = Record {
296 id: id.clone(),
297 status: "open".into(),
298 remote: remote_id.is_some(),
299 remote_id,
300 text,
301 kind,
302 atom,
303 };
304 let remote = rec.remote;
305 append(&rec)?;
306 Ok(Filed { id, remote })
307}
308
309pub fn satisfy_text(text: &str) {
312 let _g = gate();
313 let text = text.trim();
314 if text.is_empty() {
315 return;
316 }
317 let open: Vec<Record> = read_latest()
318 .into_values()
319 .filter(|rec| rec.status == "open" && rec.text.trim() == text)
320 .collect();
321 for mut rec in open {
322 rec.status = "satisfied".into();
323 let _ = append(&rec);
324 close_remote(&rec);
325 }
326}
327
328fn workspace_of(client: &PacksetClient, rec: &Record) -> String {
329 let workspace = rec
330 .atom
331 .get("workspace")
332 .and_then(Value::as_str)
333 .unwrap_or("");
334 if workspace.is_empty() {
335 client.workspace()
336 } else {
337 workspace.to_string()
338 }
339}
340
341fn close_remote(rec: &Record) {
344 if !rec.remote {
345 return;
346 }
347 let Some(remote_id) = rec.remote_id.as_deref() else {
348 return;
349 };
350 let Ok(client) = crate::pack() else {
351 return;
352 };
353 let workspace = workspace_of(&client, rec);
354 let _ = accept_on_writer(&client, &workspace, remote_id);
355}
356
357fn accepted_line(id: &str, kind: &str, posted: &Value) -> String {
358 let origin = posted
359 .get("origin")
360 .and_then(Value::as_str)
361 .filter(|origin| !origin.is_empty())
362 .unwrap_or(ORIGIN_AGENT);
363 format!(
364 "accepted {id} as {kind} origin {origin}{}\n",
365 crate::revision_note(posted)
366 )
367}
368
369fn relabel_agent(client: &PacksetClient, workspace: &str, accepted: Value) -> Result<Value> {
373 let origin = accepted.get("origin").and_then(Value::as_str).unwrap_or("");
374 if origin == ORIGIN_AGENT {
375 return Ok(accepted);
376 }
377 let Some(id) = accepted
378 .get("id")
379 .and_then(Value::as_str)
380 .filter(|id| !id.is_empty())
381 else {
382 bail!(
383 "accept stored the lesson without an id, so its origin could not be put back to agent-derived"
384 );
385 };
386 let url = format!("{}/v1/atoms/update", client.base());
387 let body = serde_json::json!({
388 "workspace": workspace,
389 "id": id,
390 "fields": {"origin": ORIGIN_AGENT},
391 });
392 match packset_client::authorize(ureq::post(&url))
393 .timeout(http_timeout())
394 .send_json(body)
395 {
396 Ok(resp) => resp
397 .into_json()
398 .context("update: the pack's answer was not json"),
399 Err(ureq::Error::Status(code, resp)) => {
400 let text = resp.into_string().unwrap_or_default();
401 bail!(
402 "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {code}: {text}"
403 );
404 }
405 Err(e) => bail!(
406 "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {e}"
407 ),
408 }
409}
410
411fn accept_on_writer(client: &PacksetClient, workspace: &str, id: &str) -> Result<Value> {
412 let url = format!("{}/v1/proposals/accept", client.base());
413 let body = serde_json::json!({"workspace": workspace, "id": id});
414 match packset_client::authorize(ureq::post(&url))
415 .timeout(http_timeout())
416 .send_json(body)
417 {
418 Ok(resp) => resp
419 .into_json()
420 .context("accept: the pack's answer was not json"),
421 Err(ureq::Error::Status(code, resp)) => {
422 let text = resp.into_string().unwrap_or_default();
423 bail!("accept: POST /v1/proposals/accept failed: {code}: {text}");
424 }
425 Err(e) => bail!("accept: POST /v1/proposals/accept failed: {e}"),
426 }
427}
428
429pub fn resolve_id(id: &str) -> Result<String> {
436 let open: Vec<String> = read_latest()
437 .into_values()
438 .filter(|r| r.status == "open")
439 .map(|r| r.id)
440 .collect();
441 crate::resolve_short_id("accept", id, &open)
442}
443
444#[must_use]
447pub fn proposed_by(id: &str) -> Option<String> {
448 let rec = read_latest().get(id.trim()).cloned()?;
449 rec.atom["source"]["session"]
450 .as_str()
451 .map(str::trim)
452 .filter(|s| !s.is_empty())
453 .map(str::to_string)
454}
455
456pub fn accept(id: &str) -> Result<String> {
459 let _g = gate();
460 let id = id.trim();
461 if id.is_empty() {
462 bail!("accept: a proposal id is required");
463 }
464 let Some(mut rec) = read_latest().get(id).cloned() else {
465 bail!("accept: no open proposal {id}");
466 };
467 match rec.status.as_str() {
468 "satisfied" => {
469 close_remote(&rec);
470 return Ok(format!("{id} was already written by remember or prefer\n"));
471 }
472 "accepted" => return Ok(format!("{id} is already in the pack\n")),
473 "open" => {}
474 _ => bail!("accept: no open proposal {id}"),
475 }
476 let client = crate::pack()?;
477 if rec.remote {
478 let remote_id = rec.remote_id.clone().unwrap_or_else(|| rec.id.clone());
479 let workspace = workspace_of(&client, &rec);
480 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
481 rec.status = "accepted".into();
482 append(&rec)?;
483 return Ok(accepted_line(&rec.id, &rec.kind, &posted));
484 }
485 match post_kept(&client, &rec.atom) {
486 Ok(posted) => {
487 rec.status = "accepted".into();
488 append(&rec)?;
489 Ok(accepted_line(&rec.id, &rec.kind, &posted))
490 }
491 Err(e) => {
492 let text = e.to_string();
493 let Some(remote_id) = held_proposal_id(&text) else {
494 return Err(e).with_context(|| format!("accept: POST /v1/atoms failed for {id}"));
495 };
496 let workspace = workspace_of(&client, &rec);
499 let posted = accept_on_writer(&client, &workspace, &remote_id)?;
500 rec.remote = true;
501 rec.remote_id = Some(remote_id);
502 rec.status = "accepted".into();
503 append(&rec)?;
504 Ok(accepted_line(&rec.id, &rec.kind, &posted))
505 }
506 }
507}