handoff-mcp-server 0.26.0 → 0.27.0
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.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
use serde::{Deserialize, Serialize};
|
|
2
|
-
|
|
3
|
-
/// Current memory schema version. Bump when `MemoryEntry` changes shape in a way
|
|
4
|
-
/// that needs migration handling on read.
|
|
5
|
-
///
|
|
6
|
-
/// v1 → v2: added `keywords` field (defaults to empty on read).
|
|
7
|
-
pub const MEMORY_SCHEMA_VERSION: u32 = 2;
|
|
8
|
-
|
|
9
|
-
/// Valid `kind` values for a memory. Free-form text is rejected so the field
|
|
10
|
-
/// stays a small, queryable enumeration.
|
|
11
|
-
pub const VALID_MEMORY_KINDS: &[&str] = &["lesson", "rule", "convention", "gotcha"];
|
|
12
|
-
|
|
13
|
-
/// Returns true if `kind` is one of the accepted memory kinds.
|
|
14
|
-
pub fn is_valid_memory_kind(kind: &str) -> bool {
|
|
15
|
-
VALID_MEMORY_KINDS.contains(&kind)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/// A single persisted memory: a long-lived, cross-session piece of project
|
|
19
|
-
/// knowledge ("lesson / rule / convention / gotcha"). One file per memory under
|
|
20
|
-
/// `.handoff/memory/`.
|
|
21
|
-
///
|
|
22
|
-
/// Token sets are intentionally **not** stored — they are recomputed from `text`
|
|
23
|
-
/// on every read so the index can never drift from the body.
|
|
24
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
25
|
-
pub struct MemoryEntry {
|
|
26
|
-
/// Schema version (= [`MEMORY_SCHEMA_VERSION`]).
|
|
27
|
-
pub version: u32,
|
|
28
|
-
/// Stable id: `m-YYYYMMDD-HHMMSS-NNNNNN`.
|
|
29
|
-
pub id: String,
|
|
30
|
-
/// The memory body (multilingual).
|
|
31
|
-
pub text: String,
|
|
32
|
-
/// One of [`VALID_MEMORY_KINDS`].
|
|
33
|
-
pub kind: String,
|
|
34
|
-
/// Free-form tags; also fed into the similarity index.
|
|
35
|
-
#[serde(default)]
|
|
36
|
-
pub tags: Vec<String>,
|
|
37
|
-
/// Subject keywords — nouns, technical terms, proper nouns that identify what
|
|
38
|
-
/// this memory is *about*. Used for BM25 matching with boosted weight.
|
|
39
|
-
/// Distinct from `tags` (classification labels) and `scope_paths` (file
|
|
40
|
-
/// prefixes). Populated by the AI at save time; defaults to empty for v1
|
|
41
|
-
/// memories.
|
|
42
|
-
#[serde(default)]
|
|
43
|
-
pub keywords: Vec<String>,
|
|
44
|
-
/// Path prefixes this memory applies to (e.g. `src/storage/`). A query whose
|
|
45
|
-
/// file paths start with one of these gets a relevance boost.
|
|
46
|
-
#[serde(default)]
|
|
47
|
-
pub scope_paths: Vec<String>,
|
|
48
|
-
/// FNV-1a hash of the canonical (tokenized) text. Drives exact-duplicate
|
|
49
|
-
/// detection and per-session re-injection tracking.
|
|
50
|
-
pub content_hash: String,
|
|
51
|
-
/// RFC3339 creation timestamp.
|
|
52
|
-
pub created_at: String,
|
|
53
|
-
/// RFC3339 last-update timestamp.
|
|
54
|
-
pub updated_at: String,
|
|
55
|
-
/// RFC3339 timestamp of the last time this memory was injected into a
|
|
56
|
-
/// session, if ever.
|
|
57
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
58
|
-
pub last_referenced_at: Option<String>,
|
|
59
|
-
/// Number of times this memory has been injected.
|
|
60
|
-
#[serde(default)]
|
|
61
|
-
pub hit_count: u64,
|
|
62
|
-
/// Ids of memories merged into this one (audit trail for AI-driven merges).
|
|
63
|
-
#[serde(default)]
|
|
64
|
-
pub superseded_ids: Vec<String>,
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
impl MemoryEntry {
|
|
68
|
-
/// Build a new entry with timestamps and content hash filled in. `now` is an
|
|
69
|
-
/// RFC3339 timestamp supplied by the caller (keeps this module clock-free
|
|
70
|
-
/// and testable).
|
|
71
|
-
pub fn new(
|
|
72
|
-
id: String,
|
|
73
|
-
text: String,
|
|
74
|
-
kind: String,
|
|
75
|
-
tags: Vec<String>,
|
|
76
|
-
keywords: Vec<String>,
|
|
77
|
-
scope_paths: Vec<String>,
|
|
78
|
-
now: String,
|
|
79
|
-
) -> Self {
|
|
80
|
-
let content_hash = lexsim::content_hash(&text);
|
|
81
|
-
MemoryEntry {
|
|
82
|
-
version: MEMORY_SCHEMA_VERSION,
|
|
83
|
-
id,
|
|
84
|
-
text,
|
|
85
|
-
kind,
|
|
86
|
-
tags,
|
|
87
|
-
keywords,
|
|
88
|
-
scope_paths,
|
|
89
|
-
content_hash,
|
|
90
|
-
created_at: now.clone(),
|
|
91
|
-
updated_at: now,
|
|
92
|
-
last_referenced_at: None,
|
|
93
|
-
hit_count: 0,
|
|
94
|
-
superseded_ids: Vec::new(),
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/// Plain-text index for Jaccard similarity: body + tags + keywords.
|
|
99
|
-
pub fn index_text(&self) -> String {
|
|
100
|
-
let mut parts = vec![self.text.clone()];
|
|
101
|
-
if !self.tags.is_empty() {
|
|
102
|
-
parts.push(self.tags.join(" "));
|
|
103
|
-
}
|
|
104
|
-
if !self.keywords.is_empty() {
|
|
105
|
-
parts.push(self.keywords.join(" "));
|
|
106
|
-
}
|
|
107
|
-
parts.join(" ")
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/// Weighted tokens for `Corpus::build_weighted_tokens`. Body + tags are
|
|
111
|
-
/// tokenized at default weight; keywords are added with weight 2.0
|
|
112
|
-
/// (doc-side TF boost), replacing the old 2x-concatenation hack.
|
|
113
|
-
pub fn index_weighted_tokens(&self) -> Vec<lexsim::WeightedToken> {
|
|
114
|
-
let mut base = vec![self.text.clone()];
|
|
115
|
-
if !self.tags.is_empty() {
|
|
116
|
-
base.push(self.tags.join(" "));
|
|
117
|
-
}
|
|
118
|
-
let mut tokens = lexsim::tokenize_weighted(&base.join(" "));
|
|
119
|
-
for kw in &self.keywords {
|
|
120
|
-
for mut wt in lexsim::tokenize_weighted(kw) {
|
|
121
|
-
wt.weight *= 2.0;
|
|
122
|
-
tokens.push(wt);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
tokens
|
|
126
|
-
}
|
|
127
|
-
}
|
package/src/storage/mod.rs
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
pub mod config;
|
|
2
|
-
pub mod docs;
|
|
3
|
-
pub mod git;
|
|
4
|
-
pub mod memory;
|
|
5
|
-
pub mod referrals;
|
|
6
|
-
pub mod sessions;
|
|
7
|
-
pub mod tasks;
|
|
8
|
-
|
|
9
|
-
use std::path::{Path, PathBuf};
|
|
10
|
-
|
|
11
|
-
use anyhow::{Context, Result};
|
|
12
|
-
|
|
13
|
-
/// Write `content` to `path` atomically: write to a sibling temp file, fsync,
|
|
14
|
-
/// then rename over the target. A rename within the same directory is atomic on
|
|
15
|
-
/// POSIX, so a concurrent reader never observes a partially-written file.
|
|
16
|
-
///
|
|
17
|
-
/// Used by every handoff write path (tasks, config, sessions, referrals) so that
|
|
18
|
-
/// the VSCode extension — which writes the same files — never reads torn data.
|
|
19
|
-
pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> Result<()> {
|
|
20
|
-
use std::io::Write;
|
|
21
|
-
|
|
22
|
-
let path = path.as_ref();
|
|
23
|
-
let dir = path.parent().ok_or_else(|| {
|
|
24
|
-
anyhow::anyhow!("Cannot determine parent directory for {}", path.display())
|
|
25
|
-
})?;
|
|
26
|
-
let file_name = path
|
|
27
|
-
.file_name()
|
|
28
|
-
.and_then(|n| n.to_str())
|
|
29
|
-
.ok_or_else(|| anyhow::anyhow!("Invalid file name for {}", path.display()))?;
|
|
30
|
-
|
|
31
|
-
// Unique-per-process temp name in the same directory (so rename is atomic).
|
|
32
|
-
let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
|
|
33
|
-
let tmp_path = dir.join(tmp_name);
|
|
34
|
-
|
|
35
|
-
let mut f = std::fs::File::create(&tmp_path)
|
|
36
|
-
.with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
|
|
37
|
-
f.write_all(content)
|
|
38
|
-
.with_context(|| format!("Failed to write temp file {}", tmp_path.display()))?;
|
|
39
|
-
f.sync_all()
|
|
40
|
-
.with_context(|| format!("Failed to sync temp file {}", tmp_path.display()))?;
|
|
41
|
-
drop(f);
|
|
42
|
-
|
|
43
|
-
std::fs::rename(&tmp_path, path).with_context(|| {
|
|
44
|
-
// Best-effort cleanup so a failed rename doesn't leave a stray temp file.
|
|
45
|
-
let _ = std::fs::remove_file(&tmp_path);
|
|
46
|
-
format!(
|
|
47
|
-
"Failed to rename {} -> {}",
|
|
48
|
-
tmp_path.display(),
|
|
49
|
-
path.display()
|
|
50
|
-
)
|
|
51
|
-
})?;
|
|
52
|
-
Ok(())
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
pub fn expand_tilde(path: &str) -> String {
|
|
56
|
-
if let Some(rest) = path.strip_prefix("~/") {
|
|
57
|
-
if let Ok(home) = std::env::var("HOME") {
|
|
58
|
-
return format!("{home}/{rest}");
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
path.to_string()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
pub fn handoff_dir(project_dir: &Path) -> PathBuf {
|
|
65
|
-
project_dir.join(".handoff")
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
pub fn ensure_handoff_exists(project_dir: &Path) -> Result<PathBuf> {
|
|
69
|
-
let dir = handoff_dir(project_dir);
|
|
70
|
-
if !dir.exists() {
|
|
71
|
-
anyhow::bail!(
|
|
72
|
-
".handoff/ directory not found in {}. Run handoff_init first.",
|
|
73
|
-
project_dir.display()
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
Ok(dir)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
pub fn init_handoff(project_dir: &Path, project_name: &str, description: &str) -> Result<()> {
|
|
80
|
-
let dir = handoff_dir(project_dir);
|
|
81
|
-
if dir.exists() {
|
|
82
|
-
anyhow::bail!(
|
|
83
|
-
".handoff/ already exists in {}. Project is already initialized.",
|
|
84
|
-
project_dir.display()
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
std::fs::create_dir_all(dir.join("sessions")).context("Failed to create .handoff/sessions/")?;
|
|
89
|
-
std::fs::create_dir_all(dir.join("tasks")).context("Failed to create .handoff/tasks/")?;
|
|
90
|
-
std::fs::create_dir_all(dir.join("memory")).context("Failed to create .handoff/memory/")?;
|
|
91
|
-
|
|
92
|
-
let config = config::Config::new(project_name, description);
|
|
93
|
-
config::write_config(&dir.join("config.toml"), &config)?;
|
|
94
|
-
|
|
95
|
-
Ok(())
|
|
96
|
-
}
|
package/src/storage/referrals.rs
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
use std::path::{Path, PathBuf};
|
|
2
|
-
|
|
3
|
-
use anyhow::{Context, Result};
|
|
4
|
-
use serde::{Deserialize, Serialize};
|
|
5
|
-
use serde_json::Value;
|
|
6
|
-
|
|
7
|
-
const VALID_REFERRAL_TYPES: &[&str] = &["improvement", "bug", "request", "info"];
|
|
8
|
-
const VALID_REFERRAL_STATUSES: &[&str] = &["open", "acknowledged", "resolved"];
|
|
9
|
-
|
|
10
|
-
pub fn is_valid_referral_type(t: &str) -> bool {
|
|
11
|
-
VALID_REFERRAL_TYPES.contains(&t)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
pub fn is_valid_referral_status(s: &str) -> bool {
|
|
15
|
-
VALID_REFERRAL_STATUSES.contains(&s)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
19
|
-
pub struct ReferralData {
|
|
20
|
-
pub id: String,
|
|
21
|
-
pub source_project: String,
|
|
22
|
-
pub source_project_dir: String,
|
|
23
|
-
pub created_at: String,
|
|
24
|
-
pub referral_type: String,
|
|
25
|
-
pub summary: String,
|
|
26
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
27
|
-
pub details: Option<String>,
|
|
28
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
29
|
-
pub priority: Option<String>,
|
|
30
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
31
|
-
pub tasks: Vec<Value>,
|
|
32
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
33
|
-
pub context: Option<Value>,
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
37
|
-
pub struct ReferralSummary {
|
|
38
|
-
pub id: String,
|
|
39
|
-
pub source_project: String,
|
|
40
|
-
pub referral_type: String,
|
|
41
|
-
pub summary: String,
|
|
42
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
43
|
-
pub priority: Option<String>,
|
|
44
|
-
pub status: String,
|
|
45
|
-
pub created_at: String,
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathBuf> {
|
|
49
|
-
std::fs::create_dir_all(referrals_dir).with_context(|| {
|
|
50
|
-
format!(
|
|
51
|
-
"Failed to create referrals dir: {}",
|
|
52
|
-
referrals_dir.display()
|
|
53
|
-
)
|
|
54
|
-
})?;
|
|
55
|
-
|
|
56
|
-
let slug = source_to_slug(&data.source_project);
|
|
57
|
-
let id_suffix = data.id.replace("ref-", "");
|
|
58
|
-
let filename = format!("{id_suffix}-{slug}.open.json");
|
|
59
|
-
let file_path = referrals_dir.join(&filename);
|
|
60
|
-
|
|
61
|
-
let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
|
|
62
|
-
crate::storage::atomic_write(&file_path, content.as_bytes())
|
|
63
|
-
.with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
|
|
64
|
-
|
|
65
|
-
Ok(file_path)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
pub fn read_referrals(
|
|
69
|
-
referrals_dir: &Path,
|
|
70
|
-
status_filter: Option<&str>,
|
|
71
|
-
) -> Result<Vec<(ReferralData, String)>> {
|
|
72
|
-
let mut results = Vec::new();
|
|
73
|
-
|
|
74
|
-
if !referrals_dir.exists() {
|
|
75
|
-
return Ok(results);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let mut entries: Vec<_> = std::fs::read_dir(referrals_dir)?
|
|
79
|
-
.filter_map(|e| e.ok())
|
|
80
|
-
.collect();
|
|
81
|
-
entries.sort_by_key(|e| e.file_name());
|
|
82
|
-
|
|
83
|
-
for entry in entries {
|
|
84
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
85
|
-
if let Some(status) = parse_referral_status(&name) {
|
|
86
|
-
if let Some(filter) = status_filter {
|
|
87
|
-
if status != filter {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
let content = std::fs::read_to_string(entry.path())
|
|
92
|
-
.with_context(|| format!("Failed to read referral: {}", entry.path().display()))?;
|
|
93
|
-
let data: ReferralData = serde_json::from_str(&content)
|
|
94
|
-
.with_context(|| format!("Failed to parse referral: {}", entry.path().display()))?;
|
|
95
|
-
results.push((data, status));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
Ok(results)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
pub fn read_referral_summaries(
|
|
103
|
-
referrals_dir: &Path,
|
|
104
|
-
status_filter: Option<&str>,
|
|
105
|
-
) -> Result<Vec<ReferralSummary>> {
|
|
106
|
-
let referrals = read_referrals(referrals_dir, status_filter)?;
|
|
107
|
-
Ok(referrals
|
|
108
|
-
.into_iter()
|
|
109
|
-
.map(|(data, status)| ReferralSummary {
|
|
110
|
-
id: data.id,
|
|
111
|
-
source_project: data.source_project,
|
|
112
|
-
referral_type: data.referral_type,
|
|
113
|
-
summary: data.summary,
|
|
114
|
-
priority: data.priority,
|
|
115
|
-
status,
|
|
116
|
-
created_at: data.created_at,
|
|
117
|
-
})
|
|
118
|
-
.collect())
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
pub fn change_referral_status(
|
|
122
|
-
referrals_dir: &Path,
|
|
123
|
-
referral_id: &str,
|
|
124
|
-
new_status: &str,
|
|
125
|
-
) -> Result<()> {
|
|
126
|
-
if !is_valid_referral_status(new_status) {
|
|
127
|
-
anyhow::bail!(
|
|
128
|
-
"Invalid referral status: '{new_status}'. Must be one of: {}",
|
|
129
|
-
VALID_REFERRAL_STATUSES.join(", ")
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
let (old_path, old_status) = find_referral_file(referrals_dir, referral_id)?
|
|
134
|
-
.ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
|
|
135
|
-
|
|
136
|
-
if old_status == new_status {
|
|
137
|
-
return Ok(());
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
let old_name = old_path
|
|
141
|
-
.file_name()
|
|
142
|
-
.ok_or_else(|| anyhow::anyhow!("Invalid referral path"))?
|
|
143
|
-
.to_string_lossy()
|
|
144
|
-
.to_string();
|
|
145
|
-
|
|
146
|
-
let new_name = old_name.replace(
|
|
147
|
-
&format!(".{old_status}.json"),
|
|
148
|
-
&format!(".{new_status}.json"),
|
|
149
|
-
);
|
|
150
|
-
let new_path = referrals_dir.join(&new_name);
|
|
151
|
-
|
|
152
|
-
std::fs::rename(&old_path, &new_path).with_context(|| {
|
|
153
|
-
format!(
|
|
154
|
-
"Failed to rename {} -> {}",
|
|
155
|
-
old_path.display(),
|
|
156
|
-
new_path.display()
|
|
157
|
-
)
|
|
158
|
-
})?;
|
|
159
|
-
|
|
160
|
-
Ok(())
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/// Read a single referral's full data by ID, returning the data and its status.
|
|
164
|
-
/// Searches every status (open / acknowledged / resolved). Matches by exact ID
|
|
165
|
-
/// first, then falls back to a unique prefix match for convenience.
|
|
166
|
-
pub fn read_referral_by_id(
|
|
167
|
-
referrals_dir: &Path,
|
|
168
|
-
referral_id: &str,
|
|
169
|
-
) -> Result<Option<(ReferralData, String)>> {
|
|
170
|
-
if !referrals_dir.exists() {
|
|
171
|
-
return Ok(None);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
let mut prefix_matches: Vec<(ReferralData, String)> = Vec::new();
|
|
175
|
-
|
|
176
|
-
for entry in std::fs::read_dir(referrals_dir)? {
|
|
177
|
-
let entry = entry?;
|
|
178
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
179
|
-
if let Some(status) = parse_referral_status(&name) {
|
|
180
|
-
let content = std::fs::read_to_string(entry.path())?;
|
|
181
|
-
if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
|
|
182
|
-
if data.id == referral_id {
|
|
183
|
-
return Ok(Some((data, status)));
|
|
184
|
-
}
|
|
185
|
-
if data.id.starts_with(referral_id) {
|
|
186
|
-
prefix_matches.push((data, status));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
match prefix_matches.len() {
|
|
193
|
-
0 => Ok(None),
|
|
194
|
-
1 => Ok(Some(prefix_matches.into_iter().next().unwrap())),
|
|
195
|
-
n => anyhow::bail!(
|
|
196
|
-
"Ambiguous referral id prefix '{referral_id}' matches {n} referrals; use the full id"
|
|
197
|
-
),
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
pub fn find_referral_file(
|
|
202
|
-
referrals_dir: &Path,
|
|
203
|
-
referral_id: &str,
|
|
204
|
-
) -> Result<Option<(PathBuf, String)>> {
|
|
205
|
-
if !referrals_dir.exists() {
|
|
206
|
-
return Ok(None);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
for entry in std::fs::read_dir(referrals_dir)? {
|
|
210
|
-
let entry = entry?;
|
|
211
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
212
|
-
if let Some(status) = parse_referral_status(&name) {
|
|
213
|
-
let content = std::fs::read_to_string(entry.path())?;
|
|
214
|
-
if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
|
|
215
|
-
if data.id == referral_id {
|
|
216
|
-
return Ok(Some((entry.path(), status)));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
Ok(None)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
fn parse_referral_status(filename: &str) -> Option<String> {
|
|
226
|
-
let name = filename.strip_suffix(".json")?;
|
|
227
|
-
for status in VALID_REFERRAL_STATUSES {
|
|
228
|
-
if name.ends_with(&format!(".{status}")) {
|
|
229
|
-
return Some(status.to_string());
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
None
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
fn source_to_slug(source: &str) -> String {
|
|
236
|
-
let slug: String = source
|
|
237
|
-
.chars()
|
|
238
|
-
.take(30)
|
|
239
|
-
.map(|c| {
|
|
240
|
-
if c.is_ascii_alphanumeric() {
|
|
241
|
-
c.to_ascii_lowercase()
|
|
242
|
-
} else {
|
|
243
|
-
'-'
|
|
244
|
-
}
|
|
245
|
-
})
|
|
246
|
-
.collect();
|
|
247
|
-
slug.trim_matches('-').to_string()
|
|
248
|
-
}
|