Skip to main content

relay_knowledge/domain/core/
source.rs

1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, error::required_text};
4
5/// Authorized source boundary for evidence and retrieval.
6#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub struct SourceScope(String);
8
9impl SourceScope {
10    /// Validates a source scope supplied by an interface adapter.
11    pub fn parse(value: impl Into<String>) -> Result<Self, DomainError> {
12        let scope = required_text("source_scope", value)?;
13        if scope.contains('\0') {
14            return Err(DomainError::invalid(
15                "source_scope",
16                "must not contain NUL bytes",
17            ));
18        }
19
20        Ok(Self(scope))
21    }
22
23    /// Returns the normalized scope identifier.
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29impl From<SourceScope> for String {
30    fn from(scope: SourceScope) -> Self {
31        scope.0
32    }
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38
39    #[test]
40    fn trims_and_preserves_source_scope() {
41        let scope = SourceScope::parse(" docs/specs ").expect("scope should parse");
42
43        assert_eq!(scope.as_str(), "docs/specs");
44    }
45
46    #[test]
47    fn rejects_empty_source_scope() {
48        let error = SourceScope::parse(" ").expect_err("empty scope should fail");
49
50        assert_eq!(error.field, "source_scope");
51    }
52
53    #[test]
54    fn rejects_nul_bytes_and_converts_to_string() {
55        let error = SourceScope::parse("repo\0branch").expect_err("NUL should fail");
56        let scope: String = SourceScope::parse("repo")
57            .expect("scope should parse")
58            .into();
59
60        assert_eq!(error.field, "source_scope");
61        assert_eq!(scope, "repo");
62    }
63}