weavatrix_semantic/policy/
mod.rs1mod seo_link;
2
3use crate::{Result, SemanticError, SemanticVector};
4pub use seo_link::SeoLinkPolicy;
5use std::collections::BTreeSet;
6use weavatrix_graph::{Edge, Graph, NodeId};
7
8pub trait LinkPolicy {
13 fn id(&self) -> &str;
15
16 fn validate(&self, graph: &Graph, vectors: &[SemanticVector]) -> Result<()>;
22
23 fn allows(&self, source: &NodeId, target: &NodeId) -> bool;
25
26 fn annotate_edge(&self, edge: Edge, _source: &NodeId, _target: &NodeId) -> Result<Edge> {
32 Ok(edge.with_attribute("policy", self.id()))
33 }
34}
35
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct AllowAllPolicy;
39
40impl LinkPolicy for AllowAllPolicy {
41 fn id(&self) -> &'static str {
42 "allow_all"
43 }
44
45 fn validate(&self, _graph: &Graph, _vectors: &[SemanticVector]) -> Result<()> {
46 Ok(())
47 }
48
49 fn allows(&self, source: &NodeId, target: &NodeId) -> bool {
50 source != target
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct SeoPage {
61 node_id: NodeId,
62 site: String,
63 canonical: String,
64 language: Option<String>,
65 eligibility: SeoEligibility,
66 existing_targets: BTreeSet<NodeId>,
67 signals: SeoSignals,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71struct SeoEligibility {
72 source: bool,
73 target: bool,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77struct SeoSignals {
78 cornerstone: bool,
79 orphan: bool,
80 target_priority: u32,
81}
82
83impl SeoPage {
84 pub fn new(
90 node_id: NodeId,
91 site: impl Into<String>,
92 canonical: impl Into<String>,
93 ) -> Result<Self> {
94 let site = site.into();
95 let canonical = canonical.into();
96 validate_page_text(&node_id, "site", &site)?;
97 validate_page_text(&node_id, "canonical", &canonical)?;
98 Ok(Self {
99 node_id,
100 site,
101 canonical,
102 language: None,
103 eligibility: SeoEligibility {
104 source: true,
105 target: true,
106 },
107 existing_targets: BTreeSet::new(),
108 signals: SeoSignals {
109 cornerstone: false,
110 orphan: false,
111 target_priority: 0,
112 },
113 })
114 }
115
116 #[must_use]
118 pub const fn node_id(&self) -> &NodeId {
119 &self.node_id
120 }
121
122 #[must_use]
124 pub fn site(&self) -> &str {
125 &self.site
126 }
127
128 #[must_use]
130 pub fn canonical(&self) -> &str {
131 &self.canonical
132 }
133
134 #[must_use]
136 pub fn language(&self) -> Option<&str> {
137 self.language.as_deref()
138 }
139
140 #[must_use]
142 pub const fn source_eligible(&self) -> bool {
143 self.eligibility.source
144 }
145
146 #[must_use]
148 pub const fn target_eligible(&self) -> bool {
149 self.eligibility.target
150 }
151
152 #[must_use]
154 pub const fn cornerstone(&self) -> bool {
155 self.signals.cornerstone
156 }
157
158 #[must_use]
160 pub const fn orphan(&self) -> bool {
161 self.signals.orphan
162 }
163
164 #[must_use]
166 pub const fn target_priority(&self) -> u32 {
167 self.signals.target_priority
168 }
169
170 pub fn with_language(mut self, language: impl Into<String>) -> Result<Self> {
176 let language = language.into();
177 if language.is_empty() {
178 return Err(SemanticError::EmptySeoLanguage {
179 node: self.node_id.to_string(),
180 });
181 }
182 if language.trim() != language {
183 return Err(SemanticError::SeoLanguageHasSurroundingWhitespace {
184 node: self.node_id.to_string(),
185 });
186 }
187 self.language = Some(language);
188 Ok(self)
189 }
190
191 #[must_use]
193 pub const fn with_source_eligible(mut self, eligible: bool) -> Self {
194 self.eligibility.source = eligible;
195 self
196 }
197
198 #[must_use]
200 pub const fn with_target_eligible(mut self, eligible: bool) -> Self {
201 self.eligibility.target = eligible;
202 self
203 }
204
205 #[must_use]
207 pub fn with_existing_target(mut self, target: NodeId) -> Self {
208 self.existing_targets.insert(target);
209 self
210 }
211
212 #[must_use]
214 pub const fn with_cornerstone(mut self, cornerstone: bool) -> Self {
215 self.signals.cornerstone = cornerstone;
216 self
217 }
218
219 #[must_use]
221 pub const fn with_orphan(mut self, orphan: bool) -> Self {
222 self.signals.orphan = orphan;
223 self
224 }
225
226 #[must_use]
228 pub const fn with_target_priority(mut self, priority: u32) -> Self {
229 self.signals.target_priority = priority;
230 self
231 }
232}
233
234fn validate_page_text(node: &NodeId, field: &str, value: &str) -> Result<()> {
235 if value.is_empty() {
236 return Err(match field {
237 "site" => SemanticError::EmptySeoSite {
238 node: node.to_string(),
239 },
240 _ => SemanticError::EmptySeoCanonical {
241 node: node.to_string(),
242 },
243 });
244 }
245 if value.trim() != value {
246 return Err(match field {
247 "site" => SemanticError::SeoSiteHasSurroundingWhitespace {
248 node: node.to_string(),
249 },
250 _ => SemanticError::SeoCanonicalHasSurroundingWhitespace {
251 node: node.to_string(),
252 },
253 });
254 }
255 Ok(())
256}