1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::Serialize;
7
8use crate::core::{
9 anchor::{self, DerivedAnchor},
10 db::Database,
11 types::{AnchorKind, Drawer, KnowledgeStatus, MemoryDomain, MemoryKind},
12 utils::current_timestamp,
13};
14
15#[derive(Debug, Clone)]
16pub struct PublishAnchorRequest {
17 pub drawer_id: String,
18 pub to: String,
19 pub target_anchor_id: Option<String>,
20 pub cwd: Option<PathBuf>,
21 pub reason: String,
22 pub reviewer: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct PublishAnchorOutcome {
27 pub drawer_id: String,
28 pub old_anchor_kind: String,
29 pub old_anchor_id: String,
30 pub old_parent_anchor_id: Option<String>,
31 pub new_anchor_kind: String,
32 pub new_anchor_id: String,
33 pub new_parent_anchor_id: Option<String>,
34}
35
36pub fn publish_anchor(
37 db: &Database,
38 request: PublishAnchorRequest,
39) -> Result<PublishAnchorOutcome> {
40 let drawer = load_publishable_knowledge(db, &request.drawer_id)?;
41 let target = resolve_target_anchor(&drawer, &request)?;
42 if drawer.anchor_kind == target.anchor_kind && drawer.anchor_id == target.anchor_id {
43 bail!("same-anchor publication is not allowed");
44 }
45 validate_publication_chain(&drawer, &target)?;
46 anchor::validate_anchor_domain(&drawer.domain, &target.anchor_kind)
47 .map_err(|message| anyhow::anyhow!(message))?;
48 anchor::validate_explicit_anchor(&target.anchor_kind, &target.anchor_id)?;
49
50 db.update_knowledge_anchor(
51 &request.drawer_id,
52 &target.anchor_kind,
53 &target.anchor_id,
54 target.parent_anchor_id.as_deref(),
55 )
56 .context("failed to update knowledge anchor")?;
57 append_audit_entry(
58 db,
59 "knowledge_publish_anchor",
60 &serde_json::json!({
61 "drawer_id": request.drawer_id,
62 "old_anchor_kind": anchor_kind_slug(&drawer.anchor_kind),
63 "old_anchor_id": drawer.anchor_id,
64 "old_parent_anchor_id": drawer.parent_anchor_id,
65 "new_anchor_kind": anchor_kind_slug(&target.anchor_kind),
66 "new_anchor_id": target.anchor_id,
67 "new_parent_anchor_id": target.parent_anchor_id,
68 "reason": request.reason,
69 "reviewer": request.reviewer,
70 }),
71 )
72 .context("failed to append audit log")?;
73
74 Ok(PublishAnchorOutcome {
75 drawer_id: drawer.id,
76 old_anchor_kind: anchor_kind_slug(&drawer.anchor_kind).to_string(),
77 old_anchor_id: drawer.anchor_id,
78 old_parent_anchor_id: drawer.parent_anchor_id,
79 new_anchor_kind: anchor_kind_slug(&target.anchor_kind).to_string(),
80 new_anchor_id: target.anchor_id,
81 new_parent_anchor_id: target.parent_anchor_id,
82 })
83}
84
85fn load_publishable_knowledge(db: &Database, drawer_id: &str) -> Result<Drawer> {
86 let drawer = db
87 .get_drawer(drawer_id)
88 .context("failed to look up drawer")?
89 .with_context(|| format!("drawer not found: {drawer_id}"))?;
90 if drawer.memory_kind != MemoryKind::Knowledge {
91 bail!("knowledge anchor publication requires a knowledge drawer");
92 }
93 match drawer.status {
94 Some(KnowledgeStatus::Promoted | KnowledgeStatus::Canonical) => Ok(drawer),
95 _ => bail!("publish-anchor requires promoted or canonical knowledge"),
96 }
97}
98
99fn resolve_target_anchor(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
100 match request.to.trim() {
101 "repo" => resolve_repo_target(drawer, request),
102 "global" => resolve_global_target(drawer, request),
103 other => bail!("unsupported publish target: {other}"),
104 }
105}
106
107fn resolve_repo_target(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
108 if let Some(anchor_id) = request.target_anchor_id.as_deref() {
109 anchor::validate_explicit_anchor(&AnchorKind::Repo, anchor_id)?;
110 return Ok(DerivedAnchor {
111 anchor_kind: AnchorKind::Repo,
112 anchor_id: anchor_id.to_string(),
113 parent_anchor_id: None,
114 });
115 }
116 if let Some(parent_anchor_id) = drawer.parent_anchor_id.as_deref() {
117 anchor::validate_explicit_anchor(&AnchorKind::Repo, parent_anchor_id)?;
118 return Ok(DerivedAnchor {
119 anchor_kind: AnchorKind::Repo,
120 anchor_id: parent_anchor_id.to_string(),
121 parent_anchor_id: None,
122 });
123 }
124 if let Some(cwd) = request.cwd.as_deref() {
125 let derived = anchor::derive_anchor_from_cwd(Some(Path::new(cwd)))?;
126 if let Some(parent_anchor_id) = derived.parent_anchor_id {
127 return Ok(DerivedAnchor {
128 anchor_kind: AnchorKind::Repo,
129 anchor_id: parent_anchor_id,
130 parent_anchor_id: None,
131 });
132 }
133 }
134 bail!("repo publication requires --target-anchor-id, parent_anchor_id, or --cwd with a repo")
135}
136
137fn resolve_global_target(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
138 if drawer.domain != MemoryDomain::Global {
139 bail!("global anchor requires domain=global");
140 }
141 let anchor_id = request
142 .target_anchor_id
143 .as_deref()
144 .context("--target-anchor-id is required for global publication")?;
145 anchor::validate_explicit_anchor(&AnchorKind::Global, anchor_id)?;
146 Ok(DerivedAnchor {
147 anchor_kind: AnchorKind::Global,
148 anchor_id: anchor_id.to_string(),
149 parent_anchor_id: None,
150 })
151}
152
153fn validate_publication_chain(drawer: &Drawer, target: &DerivedAnchor) -> Result<()> {
154 match (&drawer.anchor_kind, &target.anchor_kind) {
155 (AnchorKind::Worktree, AnchorKind::Repo) => Ok(()),
156 (AnchorKind::Repo, AnchorKind::Global) => Ok(()),
157 (AnchorKind::Worktree, AnchorKind::Global) => {
158 bail!("worktree -> global publication is not allowed")
159 }
160 (AnchorKind::Global, _) => bail!("inward publication from global is not allowed"),
161 (AnchorKind::Repo, AnchorKind::Worktree) | (AnchorKind::Worktree, AnchorKind::Worktree) => {
162 bail!("inward publication is not allowed")
163 }
164 (AnchorKind::Repo, AnchorKind::Repo) => bail!("same-anchor publication is not allowed"),
165 }
166}
167
168fn append_audit_entry(db: &Database, command: &str, details: &serde_json::Value) -> Result<()> {
169 let audit_path = db
170 .path()
171 .parent()
172 .map(|parent| parent.join("audit.jsonl"))
173 .unwrap_or_else(|| PathBuf::from("audit.jsonl"));
174 let mut file = OpenOptions::new()
175 .create(true)
176 .append(true)
177 .open(&audit_path)
178 .with_context(|| format!("failed to open audit log {}", audit_path.display()))?;
179 let entry = serde_json::json!({
180 "timestamp": current_timestamp(),
181 "command": command,
182 "details": details,
183 });
184 writeln!(file, "{entry}")
185 .with_context(|| format!("failed to write audit log {}", audit_path.display()))?;
186 Ok(())
187}
188
189fn anchor_kind_slug(value: &AnchorKind) -> &'static str {
190 match value {
191 AnchorKind::Global => "global",
192 AnchorKind::Repo => "repo",
193 AnchorKind::Worktree => "worktree",
194 }
195}