maggraph 0.2.5__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {maggraph-0.2.5 → maggraph-0.3.0}/Cargo.lock +2 -2
- {maggraph-0.2.5 → maggraph-0.3.0}/Cargo.toml +1 -1
- {maggraph-0.2.5 → maggraph-0.3.0}/PKG-INFO +1 -1
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/Cargo.toml +6 -1
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/__init__.py +1 -1
- maggraph-0.3.0/maggraph/benches/index_scale.rs +85 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/graph.rs +13 -10
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/index.rs +204 -26
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/node/mod.rs +52 -1
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/query.rs +7 -5
- {maggraph-0.2.5 → maggraph-0.3.0}/pyproject.toml +1 -1
- {maggraph-0.2.5 → maggraph-0.3.0}/README.md +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/__init__.pyi +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/benches/traversal.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/py.typed +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/agent/mod.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/config/mod.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/config/schema.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/error.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/lakehouse/cache.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/lakehouse/content.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/lakehouse/mod.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/lakehouse/resolver.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/lakehouse/uri.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/lib.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/memory.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/node/frontmatter.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/python/mod.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/recall.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/security.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/sync/engine.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/sync/lock.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/sync/mod.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/sync/policy.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/sync/repo.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/ui/api.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/ui/assets/app.js +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/ui/assets/index.html +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/ui/assets/style.css +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/ui/mod.rs +0 -0
- {maggraph-0.2.5 → maggraph-0.3.0}/maggraph/src/wikilink.rs +0 -0
|
@@ -839,7 +839,7 @@ checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
|
|
839
839
|
|
|
840
840
|
[[package]]
|
|
841
841
|
name = "maggraph"
|
|
842
|
-
version = "0.
|
|
842
|
+
version = "0.3.0"
|
|
843
843
|
dependencies = [
|
|
844
844
|
"axum",
|
|
845
845
|
"chrono",
|
|
@@ -862,7 +862,7 @@ dependencies = [
|
|
|
862
862
|
|
|
863
863
|
[[package]]
|
|
864
864
|
name = "maggraph-cli"
|
|
865
|
-
version = "0.
|
|
865
|
+
version = "0.3.0"
|
|
866
866
|
dependencies = [
|
|
867
867
|
"assert_cmd",
|
|
868
868
|
"axum",
|
|
@@ -39,12 +39,17 @@ axum = { workspace = true, optional = true }
|
|
|
39
39
|
tower = { workspace = true, optional = true }
|
|
40
40
|
tower-http = { workspace = true, optional = true }
|
|
41
41
|
serde_json = { workspace = true, optional = true }
|
|
42
|
+
tempfile.workspace = true
|
|
42
43
|
|
|
43
44
|
[dev-dependencies]
|
|
44
|
-
tempfile = { workspace = true }
|
|
45
45
|
tower = { workspace = true, features = ["util"] }
|
|
46
46
|
|
|
47
47
|
[[bench]]
|
|
48
48
|
name = "traversal"
|
|
49
49
|
path = "benches/traversal.rs"
|
|
50
50
|
harness = false
|
|
51
|
+
|
|
52
|
+
[[bench]]
|
|
53
|
+
name = "index_scale"
|
|
54
|
+
path = "benches/index_scale.rs"
|
|
55
|
+
harness = false
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
use std::env;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::time::{Duration, Instant};
|
|
4
|
+
|
|
5
|
+
use maggraph::{GraphIndex, QueryOptions};
|
|
6
|
+
use tempfile::TempDir;
|
|
7
|
+
|
|
8
|
+
fn sizes() -> Vec<usize> {
|
|
9
|
+
if let Ok(value) = env::var("MAGGRAPH_BENCH_SIZES") {
|
|
10
|
+
return value
|
|
11
|
+
.split(',')
|
|
12
|
+
.filter_map(|item| item.trim().parse().ok())
|
|
13
|
+
.collect();
|
|
14
|
+
}
|
|
15
|
+
if env::var_os("MAGGRAPH_BENCH_FULL").is_some() {
|
|
16
|
+
vec![1_000, 10_000, 100_000]
|
|
17
|
+
} else {
|
|
18
|
+
vec![1_000, 10_000]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fn write_fixture(root: &std::path::Path, count: usize) {
|
|
23
|
+
fs::create_dir_all(root).expect("create fixture root");
|
|
24
|
+
for index in 0..count {
|
|
25
|
+
let previous = index.checked_sub(1).unwrap_or(count - 1);
|
|
26
|
+
let tags = if index % 25 == 0 {
|
|
27
|
+
"tags: [\"benchmark\", \"selected\"]\n"
|
|
28
|
+
} else {
|
|
29
|
+
"tags: [\"benchmark\"]\n"
|
|
30
|
+
};
|
|
31
|
+
let markdown = format!(
|
|
32
|
+
"---\nid: \"node_{index:06}\"\ntype: \"project_fact\"\n{tags}links: [\"node_{previous:06}\"]\n---\nSynthetic graph fact {index}. Search needle group {}.\n",
|
|
33
|
+
index % 100
|
|
34
|
+
);
|
|
35
|
+
fs::write(root.join(format!("node_{index:06}.md")), markdown).expect("write node");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn timed<T>(operation: impl FnOnce() -> T) -> (T, Duration) {
|
|
40
|
+
let started = Instant::now();
|
|
41
|
+
let result = operation();
|
|
42
|
+
(result, started.elapsed())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn main() {
|
|
46
|
+
println!("MagGraph index scale benchmark");
|
|
47
|
+
println!("nodes,open_ms,search_ms,backlinks_ms,recall_us,update_us");
|
|
48
|
+
|
|
49
|
+
for count in sizes() {
|
|
50
|
+
let temp = TempDir::new().expect("temp dir");
|
|
51
|
+
let root = temp.path().join("graph");
|
|
52
|
+
write_fixture(&root, count);
|
|
53
|
+
|
|
54
|
+
let (mut index, open) = timed(|| GraphIndex::open(&root).expect("open index"));
|
|
55
|
+
let options = QueryOptions {
|
|
56
|
+
text: Some("needle group 42".into()),
|
|
57
|
+
tags: vec!["benchmark".into()],
|
|
58
|
+
limit: 50,
|
|
59
|
+
..QueryOptions::default()
|
|
60
|
+
};
|
|
61
|
+
let (results, search) = timed(|| index.search(&options).expect("search"));
|
|
62
|
+
assert!(!results.is_empty());
|
|
63
|
+
|
|
64
|
+
let (_, backlinks) = timed(|| index.backlinks("node_000042").expect("backlinks"));
|
|
65
|
+
let (_, recall) = timed(|| {
|
|
66
|
+
index
|
|
67
|
+
.recall_bundle("node_000042", "scale benchmark", 600)
|
|
68
|
+
.expect("recall bundle")
|
|
69
|
+
});
|
|
70
|
+
let (_, update) = timed(|| {
|
|
71
|
+
index
|
|
72
|
+
.update_file(root.join("node_000042.md"))
|
|
73
|
+
.expect("incremental update")
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
println!(
|
|
77
|
+
"{count},{:.3},{:.3},{:.3},{:.3},{:.3}",
|
|
78
|
+
open.as_secs_f64() * 1_000.0,
|
|
79
|
+
search.as_secs_f64() * 1_000.0,
|
|
80
|
+
backlinks.as_secs_f64() * 1_000.0,
|
|
81
|
+
recall.as_secs_f64() * 1_000_000.0,
|
|
82
|
+
update.as_secs_f64() * 1_000_000.0,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -4,8 +4,6 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
|
|
4
4
|
|
|
5
5
|
use crate::error::{MagGraphError, Result};
|
|
6
6
|
use crate::index::GraphIndex;
|
|
7
|
-
use crate::node::Node;
|
|
8
|
-
use crate::wikilink::extract_wikilink_targets;
|
|
9
7
|
|
|
10
8
|
/// Edge direction: all edges are **outgoing** from source node to target node id.
|
|
11
9
|
///
|
|
@@ -20,7 +18,7 @@ pub struct GraphAdjacency {
|
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
impl GraphAdjacency {
|
|
23
|
-
/// Build adjacency
|
|
21
|
+
/// Build adjacency from metadata and wikilinks cached in the index.
|
|
24
22
|
pub fn from_index(index: &GraphIndex) -> Result<Self> {
|
|
25
23
|
let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
|
|
26
24
|
let mut unresolved: HashMap<String, Vec<String>> = HashMap::new();
|
|
@@ -28,9 +26,13 @@ impl GraphAdjacency {
|
|
|
28
26
|
let id_set: HashSet<&str> = index.iter().map(|(id, _)| id).collect();
|
|
29
27
|
let path_stem_to_id = build_path_stem_index(index);
|
|
30
28
|
|
|
31
|
-
for (id,
|
|
32
|
-
let
|
|
33
|
-
|
|
29
|
+
for (id, entry) in index.iter() {
|
|
30
|
+
let (targets, dangling) = collect_outgoing_targets(
|
|
31
|
+
&entry.metadata.links,
|
|
32
|
+
&entry.wikilinks,
|
|
33
|
+
&id_set,
|
|
34
|
+
&path_stem_to_id,
|
|
35
|
+
);
|
|
34
36
|
if !targets.is_empty() {
|
|
35
37
|
outgoing.insert(id.to_string(), targets);
|
|
36
38
|
}
|
|
@@ -114,7 +116,8 @@ fn build_path_stem_index(index: &GraphIndex) -> HashMap<String, String> {
|
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
fn collect_outgoing_targets(
|
|
117
|
-
|
|
119
|
+
frontmatter_links: &[String],
|
|
120
|
+
wikilinks: &[String],
|
|
118
121
|
id_set: &HashSet<&str>,
|
|
119
122
|
path_stem_to_id: &HashMap<String, String>,
|
|
120
123
|
) -> (Vec<String>, Vec<String>) {
|
|
@@ -140,12 +143,12 @@ fn collect_outgoing_targets(
|
|
|
140
143
|
}
|
|
141
144
|
};
|
|
142
145
|
|
|
143
|
-
for link in
|
|
146
|
+
for link in frontmatter_links {
|
|
144
147
|
consider(link);
|
|
145
148
|
}
|
|
146
149
|
|
|
147
|
-
for target in
|
|
148
|
-
consider(
|
|
150
|
+
for target in wikilinks {
|
|
151
|
+
consider(target);
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
(resolved, dangling)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
use std::collections::HashMap;
|
|
2
2
|
use std::fs;
|
|
3
3
|
use std::path::{Path, PathBuf};
|
|
4
|
+
use std::sync::Arc;
|
|
4
5
|
|
|
5
6
|
use walkdir::WalkDir;
|
|
6
7
|
|
|
@@ -11,18 +12,26 @@ use crate::query::{changed_since, GraphChange, QueryOptions, SearchResult};
|
|
|
11
12
|
use crate::recall::{recall_bundle, RecallBundle};
|
|
12
13
|
use crate::security::validate_relative_node_path;
|
|
13
14
|
use crate::sync::WritePolicy;
|
|
15
|
+
use crate::wikilink::extract_wikilink_targets;
|
|
14
16
|
|
|
15
|
-
///
|
|
17
|
+
/// Parsed index entry used by graph, search, and retrieval operations.
|
|
16
18
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
17
19
|
pub struct NodeIndexEntry {
|
|
18
20
|
pub metadata: NodeMetadata,
|
|
19
21
|
pub relative_path: PathBuf,
|
|
22
|
+
pub(crate) body: Arc<str>,
|
|
23
|
+
pub(crate) summary: String,
|
|
24
|
+
pub(crate) wikilinks: Vec<String>,
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
impl NodeIndexEntry {
|
|
23
28
|
pub fn id(&self) -> &str {
|
|
24
29
|
&self.metadata.id
|
|
25
30
|
}
|
|
31
|
+
|
|
32
|
+
pub(crate) fn body(&self) -> &str {
|
|
33
|
+
&self.body
|
|
34
|
+
}
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/// In-memory index of markdown nodes under a graph root.
|
|
@@ -99,7 +108,7 @@ impl GraphIndex {
|
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
let node = Node::from_file(path, &self.root_path)?;
|
|
102
|
-
self.insert_entry(node.metadata, relative_path.to_path_buf())?;
|
|
111
|
+
self.insert_entry(node.metadata, relative_path.to_path_buf(), node.body)?;
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
Ok(())
|
|
@@ -158,17 +167,28 @@ impl GraphIndex {
|
|
|
158
167
|
return Ok(None);
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
if let Some(existing_id) = self.by_path.remove(&relative_path) {
|
|
162
|
-
self.by_id.remove(&existing_id);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
170
|
if !full_path.exists() {
|
|
171
|
+
if let Some(existing_id) = self.by_path.remove(&relative_path) {
|
|
172
|
+
self.by_id.remove(&existing_id);
|
|
173
|
+
}
|
|
166
174
|
return Ok(None);
|
|
167
175
|
}
|
|
168
176
|
|
|
169
177
|
let node = Node::from_file(&full_path, &self.root_path)?;
|
|
170
178
|
let id = node.id().to_string();
|
|
171
|
-
self.
|
|
179
|
+
if let Some(existing) = self.by_id.get(&id) {
|
|
180
|
+
if existing.relative_path != relative_path {
|
|
181
|
+
return Err(MagGraphError::DuplicateNodeId {
|
|
182
|
+
id,
|
|
183
|
+
first: existing.relative_path.clone(),
|
|
184
|
+
second: relative_path,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if let Some(existing_id) = self.by_path.remove(&relative_path) {
|
|
189
|
+
self.by_id.remove(&existing_id);
|
|
190
|
+
}
|
|
191
|
+
self.insert_entry(node.metadata, relative_path, node.body)?;
|
|
172
192
|
Ok(Some(id))
|
|
173
193
|
}
|
|
174
194
|
|
|
@@ -260,7 +280,11 @@ impl GraphIndex {
|
|
|
260
280
|
}
|
|
261
281
|
|
|
262
282
|
node.write_to(&self.root_path)?;
|
|
263
|
-
self.insert_entry(
|
|
283
|
+
self.insert_entry(
|
|
284
|
+
node.metadata.clone(),
|
|
285
|
+
node.relative_path.clone(),
|
|
286
|
+
node.body.clone(),
|
|
287
|
+
)?;
|
|
264
288
|
Ok(node)
|
|
265
289
|
}
|
|
266
290
|
|
|
@@ -298,6 +322,9 @@ impl GraphIndex {
|
|
|
298
322
|
NodeIndexEntry {
|
|
299
323
|
metadata: node.metadata,
|
|
300
324
|
relative_path: node.relative_path,
|
|
325
|
+
summary: crate::query::summarize_text(&node.body),
|
|
326
|
+
wikilinks: extract_wikilink_targets(&node.body),
|
|
327
|
+
body: Arc::from(node.body),
|
|
301
328
|
},
|
|
302
329
|
);
|
|
303
330
|
Ok(())
|
|
@@ -317,16 +344,18 @@ impl GraphIndex {
|
|
|
317
344
|
fn delete_node_unchecked(&mut self, id: &str) -> Result<()> {
|
|
318
345
|
let entry = self
|
|
319
346
|
.by_id
|
|
320
|
-
.
|
|
347
|
+
.get(id)
|
|
348
|
+
.cloned()
|
|
321
349
|
.ok_or_else(|| MagGraphError::NodeNotFound { id: id.to_string() })?;
|
|
322
350
|
|
|
323
|
-
self.by_path.remove(&entry.relative_path);
|
|
324
|
-
|
|
325
351
|
let path = self.root_path.join(&entry.relative_path);
|
|
326
352
|
if path.exists() {
|
|
327
353
|
fs::remove_file(&path).map_err(|source| MagGraphError::NodeDelete { path, source })?;
|
|
328
354
|
}
|
|
329
355
|
|
|
356
|
+
self.by_id.remove(id);
|
|
357
|
+
self.by_path.remove(&entry.relative_path);
|
|
358
|
+
|
|
330
359
|
Ok(())
|
|
331
360
|
}
|
|
332
361
|
|
|
@@ -362,28 +391,55 @@ impl GraphIndex {
|
|
|
362
391
|
}
|
|
363
392
|
let mut target = self.read_node(target_id)?;
|
|
364
393
|
let source = self.read_node(source_id)?;
|
|
365
|
-
|
|
366
|
-
|
|
394
|
+
let source_value = serde_yaml::Value::String(source_id.to_string());
|
|
395
|
+
let already_merged = target
|
|
396
|
+
.metadata
|
|
397
|
+
.extra
|
|
398
|
+
.get("merged_from")
|
|
399
|
+
.and_then(serde_yaml::Value::as_sequence)
|
|
400
|
+
.is_some_and(|values| values.contains(&source_value));
|
|
401
|
+
if !already_merged {
|
|
402
|
+
if !target.body.ends_with('\n') {
|
|
403
|
+
target.body.push('\n');
|
|
404
|
+
}
|
|
405
|
+
target.body.push_str(&format!(
|
|
406
|
+
"\n---\n\n## Merged from `{}`\n\n{}",
|
|
407
|
+
source.id(),
|
|
408
|
+
source.body
|
|
409
|
+
));
|
|
410
|
+
for link in source.metadata.links {
|
|
411
|
+
if link != target_id && !target.metadata.links.contains(&link) {
|
|
412
|
+
target.metadata.links.push(link);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
367
415
|
}
|
|
368
|
-
target
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
416
|
+
let merged_from = target
|
|
417
|
+
.metadata
|
|
418
|
+
.extra
|
|
419
|
+
.entry("merged_from".to_string())
|
|
420
|
+
.or_insert_with(|| serde_yaml::Value::Sequence(Vec::new()));
|
|
421
|
+
let provenance = match merged_from {
|
|
422
|
+
serde_yaml::Value::Sequence(values) => values,
|
|
423
|
+
_ => {
|
|
424
|
+
*merged_from = serde_yaml::Value::Sequence(Vec::new());
|
|
425
|
+
merged_from
|
|
426
|
+
.as_sequence_mut()
|
|
427
|
+
.expect("sequence inserted above")
|
|
376
428
|
}
|
|
429
|
+
};
|
|
430
|
+
if !provenance.contains(&source_value) {
|
|
431
|
+
provenance.push(source_value);
|
|
377
432
|
}
|
|
378
|
-
target.metadata.extra.insert(
|
|
379
|
-
"merged_from".to_string(),
|
|
380
|
-
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(source_id.to_string())]),
|
|
381
|
-
);
|
|
382
433
|
self.update_node(target)?;
|
|
383
434
|
self.delete_node(source_id)
|
|
384
435
|
}
|
|
385
436
|
|
|
386
|
-
fn insert_entry(
|
|
437
|
+
fn insert_entry(
|
|
438
|
+
&mut self,
|
|
439
|
+
metadata: NodeMetadata,
|
|
440
|
+
relative_path: PathBuf,
|
|
441
|
+
body: String,
|
|
442
|
+
) -> Result<()> {
|
|
387
443
|
let id = metadata.id.clone();
|
|
388
444
|
|
|
389
445
|
if let Some(existing) = self.by_id.get(&id) {
|
|
@@ -406,6 +462,9 @@ impl GraphIndex {
|
|
|
406
462
|
NodeIndexEntry {
|
|
407
463
|
metadata,
|
|
408
464
|
relative_path: relative_path.clone(),
|
|
465
|
+
summary: crate::query::summarize_text(&body),
|
|
466
|
+
wikilinks: extract_wikilink_targets(&body),
|
|
467
|
+
body: Arc::from(body),
|
|
409
468
|
},
|
|
410
469
|
);
|
|
411
470
|
self.by_path.insert(relative_path, id);
|
|
@@ -677,4 +736,123 @@ tags: ["agent"]
|
|
|
677
736
|
assert!(index.contains("fresh"));
|
|
678
737
|
assert!(!index.changed_since(0).is_empty());
|
|
679
738
|
}
|
|
739
|
+
|
|
740
|
+
#[test]
|
|
741
|
+
fn malformed_incremental_update_keeps_last_valid_index_state() {
|
|
742
|
+
let temp = TempDir::new().expect("temp dir");
|
|
743
|
+
let root = temp.path().join("graph");
|
|
744
|
+
write_example_graph(&root);
|
|
745
|
+
let mut index = GraphIndex::open(&root).expect("open index");
|
|
746
|
+
|
|
747
|
+
fs::write(root.join("welcome.md"), "# truncated without frontmatter")
|
|
748
|
+
.expect("write malformed external edit");
|
|
749
|
+
index
|
|
750
|
+
.update_file("welcome.md")
|
|
751
|
+
.expect_err("malformed update should fail");
|
|
752
|
+
|
|
753
|
+
assert!(index.contains("welcome"));
|
|
754
|
+
let results = index
|
|
755
|
+
.search(&QueryOptions {
|
|
756
|
+
text: Some("Welcome".into()),
|
|
757
|
+
..QueryOptions::default()
|
|
758
|
+
})
|
|
759
|
+
.expect("cached index remains searchable");
|
|
760
|
+
assert_eq!(results[0].id, "welcome");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
#[test]
|
|
764
|
+
fn failed_disk_delete_keeps_index_entry() {
|
|
765
|
+
let temp = TempDir::new().expect("temp dir");
|
|
766
|
+
let root = temp.path().join("graph");
|
|
767
|
+
write_example_graph(&root);
|
|
768
|
+
let mut index = GraphIndex::open(&root).expect("open index");
|
|
769
|
+
let path = root.join("getting_started.md");
|
|
770
|
+
|
|
771
|
+
fs::remove_file(&path).expect("remove fixture file");
|
|
772
|
+
fs::create_dir(&path).expect("replace file with directory");
|
|
773
|
+
let error = index
|
|
774
|
+
.delete_node("getting_started")
|
|
775
|
+
.expect_err("directory cannot be removed as a file");
|
|
776
|
+
|
|
777
|
+
assert!(error.to_string().contains("delete"));
|
|
778
|
+
assert!(index.contains("getting_started"));
|
|
779
|
+
assert_eq!(
|
|
780
|
+
index.get("getting_started").unwrap().relative_path,
|
|
781
|
+
PathBuf::from("getting_started.md")
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
#[test]
|
|
786
|
+
fn repeated_merges_preserve_all_provenance() {
|
|
787
|
+
let temp = TempDir::new().expect("temp dir");
|
|
788
|
+
let root = temp.path().join("graph");
|
|
789
|
+
write_example_graph(&root);
|
|
790
|
+
let mut index = GraphIndex::open(&root).expect("open index");
|
|
791
|
+
index
|
|
792
|
+
.create_node(NewNode {
|
|
793
|
+
metadata: NodeMetadata {
|
|
794
|
+
id: "third".into(),
|
|
795
|
+
node_type: "note".into(),
|
|
796
|
+
source: None,
|
|
797
|
+
links: vec![],
|
|
798
|
+
extra: Default::default(),
|
|
799
|
+
},
|
|
800
|
+
body: "Third body\n".into(),
|
|
801
|
+
relative_path: PathBuf::from("third.md"),
|
|
802
|
+
})
|
|
803
|
+
.expect("create third node");
|
|
804
|
+
|
|
805
|
+
index
|
|
806
|
+
.merge_nodes("welcome", "getting_started")
|
|
807
|
+
.expect("first merge");
|
|
808
|
+
index.merge_nodes("welcome", "third").expect("second merge");
|
|
809
|
+
|
|
810
|
+
let merged = index.read_node("welcome").expect("merged target");
|
|
811
|
+
let provenance = merged
|
|
812
|
+
.metadata
|
|
813
|
+
.extra
|
|
814
|
+
.get("merged_from")
|
|
815
|
+
.and_then(serde_yaml::Value::as_sequence)
|
|
816
|
+
.expect("merged_from sequence");
|
|
817
|
+
assert_eq!(
|
|
818
|
+
provenance,
|
|
819
|
+
&vec![
|
|
820
|
+
serde_yaml::Value::String("getting_started".into()),
|
|
821
|
+
serde_yaml::Value::String("third".into()),
|
|
822
|
+
]
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
#[test]
|
|
827
|
+
fn merge_retry_does_not_duplicate_already_recorded_source() {
|
|
828
|
+
let temp = TempDir::new().expect("temp dir");
|
|
829
|
+
let root = temp.path().join("graph");
|
|
830
|
+
write_example_graph(&root);
|
|
831
|
+
let mut index = GraphIndex::open(&root).expect("open index");
|
|
832
|
+
let source_body = index.read_node("getting_started").expect("source").body;
|
|
833
|
+
let mut target = index.read_node("welcome").expect("target");
|
|
834
|
+
target.body.push_str(&format!(
|
|
835
|
+
"\n---\n\n## Merged from `getting_started`\n\n{source_body}"
|
|
836
|
+
));
|
|
837
|
+
target.metadata.extra.insert(
|
|
838
|
+
"merged_from".into(),
|
|
839
|
+
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("getting_started".into())]),
|
|
840
|
+
);
|
|
841
|
+
index
|
|
842
|
+
.update_node(target)
|
|
843
|
+
.expect("record interrupted merge state");
|
|
844
|
+
|
|
845
|
+
index
|
|
846
|
+
.merge_nodes("welcome", "getting_started")
|
|
847
|
+
.expect("retry merge");
|
|
848
|
+
let merged = index.read_node("welcome").expect("merged target");
|
|
849
|
+
assert_eq!(
|
|
850
|
+
merged
|
|
851
|
+
.body
|
|
852
|
+
.matches("## Merged from `getting_started`")
|
|
853
|
+
.count(),
|
|
854
|
+
1
|
|
855
|
+
);
|
|
856
|
+
assert!(!index.contains("getting_started"));
|
|
857
|
+
}
|
|
680
858
|
}
|
|
@@ -2,6 +2,7 @@ mod frontmatter;
|
|
|
2
2
|
|
|
3
3
|
use std::collections::BTreeMap;
|
|
4
4
|
use std::fs;
|
|
5
|
+
use std::io::Write;
|
|
5
6
|
use std::path::{Path, PathBuf};
|
|
6
7
|
|
|
7
8
|
use serde::{Deserialize, Serialize};
|
|
@@ -93,7 +94,26 @@ impl Node {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
let contents = self.to_markdown()?;
|
|
96
|
-
|
|
97
|
+
let mut pending =
|
|
98
|
+
tempfile::NamedTempFile::new_in(path.parent().unwrap_or_else(|| Path::new(".")))
|
|
99
|
+
.map_err(|source| MagGraphError::NodeWrite {
|
|
100
|
+
path: path.clone(),
|
|
101
|
+
source,
|
|
102
|
+
})?;
|
|
103
|
+
pending
|
|
104
|
+
.write_all(contents.as_bytes())
|
|
105
|
+
.and_then(|_| pending.as_file().sync_all())
|
|
106
|
+
.map_err(|source| MagGraphError::NodeWrite {
|
|
107
|
+
path: path.clone(),
|
|
108
|
+
source,
|
|
109
|
+
})?;
|
|
110
|
+
pending
|
|
111
|
+
.persist(&path)
|
|
112
|
+
.map_err(|error| MagGraphError::NodeWrite {
|
|
113
|
+
path,
|
|
114
|
+
source: error.error,
|
|
115
|
+
})?;
|
|
116
|
+
Ok(())
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
pub fn id(&self) -> &str {
|
|
@@ -228,6 +248,37 @@ links: ["getting_started"]
|
|
|
228
248
|
assert_eq!(reloaded, node);
|
|
229
249
|
}
|
|
230
250
|
|
|
251
|
+
#[test]
|
|
252
|
+
fn atomic_replacement_ignores_orphaned_pending_files() {
|
|
253
|
+
let temp = TempDir::new().expect("temp dir");
|
|
254
|
+
let root = temp.path().join("graph");
|
|
255
|
+
fs::create_dir_all(&root).expect("create root");
|
|
256
|
+
|
|
257
|
+
let mut node = Node {
|
|
258
|
+
metadata: NodeMetadata {
|
|
259
|
+
id: "durable".into(),
|
|
260
|
+
node_type: "note".into(),
|
|
261
|
+
source: None,
|
|
262
|
+
links: vec![],
|
|
263
|
+
extra: BTreeMap::new(),
|
|
264
|
+
},
|
|
265
|
+
body: "original\n".into(),
|
|
266
|
+
relative_path: PathBuf::from("durable.md"),
|
|
267
|
+
};
|
|
268
|
+
node.write_to(&root).expect("initial write");
|
|
269
|
+
|
|
270
|
+
fs::write(root.join(".tmp-interrupted-write"), "partial").expect("orphan temp");
|
|
271
|
+
node.body = "replacement\n".into();
|
|
272
|
+
node.write_to(&root).expect("atomic replacement");
|
|
273
|
+
|
|
274
|
+
let reloaded = Node::from_file(root.join("durable.md"), &root).expect("reload");
|
|
275
|
+
assert_eq!(reloaded.body, "replacement\n");
|
|
276
|
+
assert_eq!(
|
|
277
|
+
fs::read_to_string(root.join(".tmp-interrupted-write")).unwrap(),
|
|
278
|
+
"partial"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
231
282
|
#[test]
|
|
232
283
|
fn rejects_missing_frontmatter() {
|
|
233
284
|
let err = parse_markdown_node("# No frontmatter", Path::new("x.md")).expect_err("error");
|
|
@@ -63,7 +63,6 @@ pub fn search_index(index: &GraphIndex, options: &QueryOptions) -> Result<Vec<Se
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
let node = index.read_node(id)?;
|
|
67
66
|
let mut score = 0;
|
|
68
67
|
let mut matched = Vec::new();
|
|
69
68
|
if needle.is_empty() {
|
|
@@ -72,7 +71,7 @@ pub fn search_index(index: &GraphIndex, options: &QueryOptions) -> Result<Vec<Se
|
|
|
72
71
|
} else {
|
|
73
72
|
score += score_text(id, &needle, 30, "id", &mut matched);
|
|
74
73
|
score += score_text(&entry.metadata.node_type, &needle, 12, "type", &mut matched);
|
|
75
|
-
score += score_text(
|
|
74
|
+
score += score_text(entry.body(), &needle, 6, "body", &mut matched);
|
|
76
75
|
for link in &entry.metadata.links {
|
|
77
76
|
score += score_text(link, &needle, 10, "links", &mut matched);
|
|
78
77
|
}
|
|
@@ -98,7 +97,7 @@ pub fn search_index(index: &GraphIndex, options: &QueryOptions) -> Result<Vec<Se
|
|
|
98
97
|
relative_path: entry.relative_path.display().to_string(),
|
|
99
98
|
score,
|
|
100
99
|
matched,
|
|
101
|
-
summary:
|
|
100
|
+
summary: entry.summary.clone(),
|
|
102
101
|
modified_unix,
|
|
103
102
|
});
|
|
104
103
|
}
|
|
@@ -139,8 +138,11 @@ pub fn is_suppressed(node: &Node) -> bool {
|
|
|
139
138
|
}
|
|
140
139
|
|
|
141
140
|
pub fn summarize_body(node: &Node) -> String {
|
|
142
|
-
node.body
|
|
143
|
-
|
|
141
|
+
summarize_text(&node.body)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pub(crate) fn summarize_text(body: &str) -> String {
|
|
145
|
+
body.lines()
|
|
144
146
|
.filter(|line| !line.trim().is_empty())
|
|
145
147
|
.take(3)
|
|
146
148
|
.collect::<Vec<_>>()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|