Skip to main content

lean_ctx/core/property_graph/
node.rs

1//! Node types and CRUD operations for graph nodes.
2
3use rusqlite::{params, Connection, OptionalExtension};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum NodeKind {
7    File,
8    Symbol,
9    Module,
10    Commit,
11    Test,
12    CIRun,
13    Knowledge,
14    Issue,
15}
16
17impl NodeKind {
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            Self::File => "file",
21            Self::Symbol => "symbol",
22            Self::Module => "module",
23            Self::Commit => "commit",
24            Self::Test => "test",
25            Self::CIRun => "ci_run",
26            Self::Knowledge => "knowledge",
27            Self::Issue => "issue",
28        }
29    }
30
31    pub fn parse(s: &str) -> Self {
32        match s {
33            "symbol" => Self::Symbol,
34            "module" => Self::Module,
35            "commit" => Self::Commit,
36            "test" => Self::Test,
37            "ci_run" => Self::CIRun,
38            "knowledge" => Self::Knowledge,
39            "issue" => Self::Issue,
40            _ => Self::File,
41        }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct Node {
47    pub id: Option<i64>,
48    pub kind: NodeKind,
49    pub name: String,
50    pub file_path: String,
51    pub line_start: Option<usize>,
52    pub line_end: Option<usize>,
53    pub metadata: Option<String>,
54}
55
56impl Node {
57    pub fn file(path: &str) -> Self {
58        Self {
59            id: None,
60            kind: NodeKind::File,
61            name: path.to_string(),
62            file_path: path.to_string(),
63            line_start: None,
64            line_end: None,
65            metadata: None,
66        }
67    }
68
69    pub fn symbol(name: &str, file_path: &str, kind: NodeKind) -> Self {
70        Self {
71            id: None,
72            kind,
73            name: name.to_string(),
74            file_path: file_path.to_string(),
75            line_start: None,
76            line_end: None,
77            metadata: None,
78        }
79    }
80
81    pub fn with_lines(mut self, start: usize, end: usize) -> Self {
82        self.line_start = Some(start);
83        self.line_end = Some(end);
84        self
85    }
86
87    pub fn with_metadata(mut self, meta: &str) -> Self {
88        self.metadata = Some(meta.to_string());
89        self
90    }
91
92    pub fn commit(hash: &str, message: &str) -> Self {
93        Self {
94            id: None,
95            kind: NodeKind::Commit,
96            name: hash.to_string(),
97            file_path: String::new(),
98            line_start: None,
99            line_end: None,
100            metadata: Some(message.to_string()),
101        }
102    }
103
104    pub fn test(path: &str, test_name: &str) -> Self {
105        Self {
106            id: None,
107            kind: NodeKind::Test,
108            name: test_name.to_string(),
109            file_path: path.to_string(),
110            line_start: None,
111            line_end: None,
112            metadata: None,
113        }
114    }
115
116    pub fn knowledge(id: &str, summary: &str) -> Self {
117        Self {
118            id: None,
119            kind: NodeKind::Knowledge,
120            name: id.to_string(),
121            file_path: String::new(),
122            line_start: None,
123            line_end: None,
124            metadata: Some(summary.to_string()),
125        }
126    }
127
128    pub fn issue(id: &str, title: &str) -> Self {
129        Self {
130            id: None,
131            kind: NodeKind::Issue,
132            name: id.to_string(),
133            file_path: String::new(),
134            line_start: None,
135            line_end: None,
136            metadata: Some(title.to_string()),
137        }
138    }
139}
140
141pub(super) fn upsert(conn: &Connection, node: &Node) -> anyhow::Result<i64> {
142    conn.execute(
143        "INSERT INTO nodes (kind, name, file_path, line_start, line_end, metadata)
144         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
145         ON CONFLICT(kind, name, file_path) DO UPDATE SET
146            line_start = excluded.line_start,
147            line_end = excluded.line_end,
148            metadata = excluded.metadata",
149        params![
150            node.kind.as_str(),
151            node.name,
152            node.file_path,
153            node.line_start.map(|v| v as i64),
154            node.line_end.map(|v| v as i64),
155            node.metadata,
156        ],
157    )?;
158
159    let id: i64 = conn.query_row(
160        "SELECT id FROM nodes WHERE kind = ?1 AND name = ?2 AND file_path = ?3",
161        params![node.kind.as_str(), node.name, node.file_path],
162        |row| row.get(0),
163    )?;
164
165    Ok(id)
166}
167
168pub(super) fn get_by_path(conn: &Connection, file_path: &str) -> anyhow::Result<Option<Node>> {
169    let result = conn
170        .query_row(
171            "SELECT id, kind, name, file_path, line_start, line_end, metadata
172             FROM nodes WHERE kind = 'file' AND file_path = ?1",
173            params![file_path],
174            |row| {
175                Ok(Node {
176                    id: Some(row.get(0)?),
177                    kind: NodeKind::parse(&row.get::<_, String>(1)?),
178                    name: row.get(2)?,
179                    file_path: row.get(3)?,
180                    line_start: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
181                    line_end: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
182                    metadata: row.get(6)?,
183                })
184            },
185        )
186        .optional()?;
187    Ok(result)
188}
189
190pub(super) fn get_by_symbol(
191    conn: &Connection,
192    name: &str,
193    file_path: &str,
194) -> anyhow::Result<Option<Node>> {
195    let result = conn
196        .query_row(
197            "SELECT id, kind, name, file_path, line_start, line_end, metadata
198             FROM nodes WHERE name = ?1 AND file_path = ?2 AND kind != 'file'",
199            params![name, file_path],
200            |row| {
201                Ok(Node {
202                    id: Some(row.get(0)?),
203                    kind: NodeKind::parse(&row.get::<_, String>(1)?),
204                    name: row.get(2)?,
205                    file_path: row.get(3)?,
206                    line_start: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
207                    line_end: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
208                    metadata: row.get(6)?,
209                })
210            },
211        )
212        .optional()?;
213    Ok(result)
214}
215
216pub(super) fn remove_by_file(conn: &Connection, file_path: &str) -> anyhow::Result<()> {
217    conn.execute(
218        "DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file_path = ?1)
219         OR target_id IN (SELECT id FROM nodes WHERE file_path = ?1)",
220        params![file_path],
221    )?;
222    conn.execute("DELETE FROM nodes WHERE file_path = ?1", params![file_path])?;
223    Ok(())
224}
225
226pub(super) fn count(conn: &Connection) -> anyhow::Result<usize> {
227    let c: i64 = conn.query_row("SELECT COUNT(*) FROM nodes", [], |row| row.get(0))?;
228    Ok(c as usize)
229}