@lamentis/naome 1.3.10 → 1.3.11
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/Cargo.lock +2 -2
- package/README.md +5 -0
- package/crates/naome-cli/Cargo.toml +1 -1
- package/crates/naome-cli/src/architecture_commands.rs +6 -2
- package/crates/naome-core/Cargo.toml +1 -1
- package/crates/naome-core/src/architecture/config/parser/sections.rs +17 -0
- package/crates/naome-core/src/architecture/config/parser.rs +1 -0
- package/crates/naome-core/src/architecture/config.rs +12 -0
- package/crates/naome-core/src/architecture/output.rs +1 -1
- package/crates/naome-core/src/architecture/rules.rs +72 -0
- package/crates/naome-core/src/architecture/scan/graph_builder/emit.rs +63 -1
- package/crates/naome-core/src/architecture/scan/graph_builder/facts.rs +2 -3
- package/crates/naome-core/src/architecture/scan/graph_builder.rs +78 -1
- package/crates/naome-core/src/architecture/scan/imports/extractors.rs +407 -0
- package/crates/naome-core/src/architecture/scan/imports/resolver.rs +334 -0
- package/crates/naome-core/src/architecture/scan/imports.rs +59 -0
- package/crates/naome-core/src/architecture/scan.rs +20 -0
- package/crates/naome-core/tests/architecture.rs +346 -7
- package/native/darwin-arm64/naome +0 -0
- package/native/linux-x64/naome +0 -0
- package/package.json +1 -1
- package/templates/naome-root/.naome/manifest.json +2 -2
- package/templates/naome-root/docs/naome/architecture-fitness.md +19 -7
package/Cargo.lock
CHANGED
|
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
|
|
76
76
|
|
|
77
77
|
[[package]]
|
|
78
78
|
name = "naome-cli"
|
|
79
|
-
version = "1.3.
|
|
79
|
+
version = "1.3.11"
|
|
80
80
|
dependencies = [
|
|
81
81
|
"naome-core",
|
|
82
82
|
"serde_json",
|
|
@@ -84,7 +84,7 @@ dependencies = [
|
|
|
84
84
|
|
|
85
85
|
[[package]]
|
|
86
86
|
name = "naome-core"
|
|
87
|
-
version = "1.3.
|
|
87
|
+
version = "1.3.11"
|
|
88
88
|
dependencies = [
|
|
89
89
|
"serde",
|
|
90
90
|
"serde_json",
|
package/README.md
CHANGED
|
@@ -88,6 +88,11 @@ naome task render-state --write --json
|
|
|
88
88
|
naome commit -m "type(scope): summary"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
Architecture fitness builds a language-agnostic graph, extracts direct imports
|
|
92
|
+
for TypeScript, JavaScript, Rust, Python, and Go, and can enforce configured
|
|
93
|
+
layer dependency rules such as keeping domain code independent from
|
|
94
|
+
infrastructure adapters.
|
|
95
|
+
|
|
91
96
|
`naome sync` installs or repairs the local harness files. It does not run a
|
|
92
97
|
hidden full-repository quality scan. It also migrates any active legacy
|
|
93
98
|
task-state into the local task ledger automatically and untracks local
|
|
@@ -52,8 +52,12 @@ fn run_arch_explain(root: &Path, args: &[String]) -> Result<(), Box<dyn std::err
|
|
|
52
52
|
"schema": "naome.arch.explain.v1",
|
|
53
53
|
"layers": scan.config.layers.keys().collect::<Vec<_>>(),
|
|
54
54
|
"contexts": scan.config.contexts.keys().collect::<Vec<_>>(),
|
|
55
|
-
"rules": [
|
|
56
|
-
|
|
55
|
+
"rules": [
|
|
56
|
+
"arch.max_file_lines",
|
|
57
|
+
"arch.generated_manual_boundary",
|
|
58
|
+
"arch.no_forbidden_layer_dependencies"
|
|
59
|
+
],
|
|
60
|
+
"extractors": ["path", "typescript", "javascript", "rust", "python", "go"]
|
|
57
61
|
}))?
|
|
58
62
|
);
|
|
59
63
|
} else {
|
|
@@ -36,6 +36,23 @@ pub(super) fn parse_contexts(parser: &mut ConfigParser<'_>) -> Result<(), NaomeE
|
|
|
36
36
|
Ok(())
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
pub(super) fn parse_allowed_dependencies(parser: &mut ConfigParser<'_>) -> Result<(), NaomeError> {
|
|
40
|
+
while let Some((_, line)) = parser.peek_line() {
|
|
41
|
+
if indent(line) == 0 {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
let (line_number, line) = parser.next_line().unwrap();
|
|
45
|
+
let name = section_name(line, 2)
|
|
46
|
+
.ok_or_else(|| parser.error(line_number, "expected dependency source".to_string()))?;
|
|
47
|
+
let dependencies = parser.parse_list(4)?;
|
|
48
|
+
parser
|
|
49
|
+
.config
|
|
50
|
+
.allowed_dependencies
|
|
51
|
+
.insert(name.to_string(), dependencies);
|
|
52
|
+
}
|
|
53
|
+
Ok(())
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
fn parse_context(parser: &mut ConfigParser<'_>) -> Result<ContextConfig, NaomeError> {
|
|
40
57
|
let mut context = ContextConfig::default();
|
|
41
58
|
while let Some((_, child)) = parser.peek_line() {
|
|
@@ -40,6 +40,7 @@ impl<'a> ConfigParser<'a> {
|
|
|
40
40
|
match line.trim() {
|
|
41
41
|
"layers:" => sections::parse_layers(self)?,
|
|
42
42
|
"contexts:" => sections::parse_contexts(self)?,
|
|
43
|
+
"allowed_dependencies:" => sections::parse_allowed_dependencies(self)?,
|
|
43
44
|
"rules:" => sections::parse_rules(self)?,
|
|
44
45
|
"ignore:" => sections::parse_ignore(self)?,
|
|
45
46
|
other => {
|
|
@@ -12,6 +12,7 @@ mod parser;
|
|
|
12
12
|
pub struct ArchitectureConfig {
|
|
13
13
|
pub layers: BTreeMap<String, LayerConfig>,
|
|
14
14
|
pub contexts: BTreeMap<String, ContextConfig>,
|
|
15
|
+
pub allowed_dependencies: BTreeMap<String, Vec<String>>,
|
|
15
16
|
pub rules: BTreeMap<String, RuleConfig>,
|
|
16
17
|
pub ignore: Vec<IgnoreRule>,
|
|
17
18
|
}
|
|
@@ -77,6 +78,14 @@ layers:
|
|
|
77
78
|
paths:
|
|
78
79
|
- "src/infrastructure/**"
|
|
79
80
|
|
|
81
|
+
allowed_dependencies:
|
|
82
|
+
application:
|
|
83
|
+
- domain
|
|
84
|
+
- infrastructure
|
|
85
|
+
domain:
|
|
86
|
+
infrastructure:
|
|
87
|
+
- domain
|
|
88
|
+
|
|
80
89
|
contexts:
|
|
81
90
|
default:
|
|
82
91
|
paths:
|
|
@@ -92,6 +101,9 @@ rules:
|
|
|
92
101
|
generated_manual_boundary:
|
|
93
102
|
enabled: true
|
|
94
103
|
severity: error
|
|
104
|
+
no_forbidden_layer_dependencies:
|
|
105
|
+
enabled: true
|
|
106
|
+
severity: error
|
|
95
107
|
|
|
96
108
|
ignore:
|
|
97
109
|
- path: "generated/**"
|
|
@@ -163,7 +163,7 @@ pub fn format_architecture_explain(scan: &ArchitectureScanReport) -> String {
|
|
|
163
163
|
.collect::<Vec<_>>()
|
|
164
164
|
.join(", ");
|
|
165
165
|
format!(
|
|
166
|
-
"NAOME Architecture Fitness\nrules: max_file_lines, generated_manual_boundary\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\n",
|
|
166
|
+
"NAOME Architecture Fitness\nrules: max_file_lines, generated_manual_boundary, no_forbidden_layer_dependencies\nlayers: {}\ncontexts: {}\npath extractor: enabled for every repository\nimport extractors: typescript, javascript, rust, python, go\n",
|
|
167
167
|
empty_label(&layers),
|
|
168
168
|
empty_label(&contexts)
|
|
169
169
|
)
|
|
@@ -5,6 +5,7 @@ use super::output::{
|
|
|
5
5
|
Severity,
|
|
6
6
|
};
|
|
7
7
|
use super::scan::ArchitectureScanReport;
|
|
8
|
+
use crate::architecture::model::ArchitectureEdgeKind;
|
|
8
9
|
|
|
9
10
|
pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
|
|
10
11
|
let mut violations = Vec::new();
|
|
@@ -12,6 +13,7 @@ pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
|
|
|
12
13
|
|
|
13
14
|
validate_max_file_lines(&scan, &mut violations, &mut rules_executed);
|
|
14
15
|
validate_generated_manual_boundary(&scan, &mut violations, &mut rules_executed);
|
|
16
|
+
validate_forbidden_layer_dependencies(&scan, &mut violations, &mut rules_executed);
|
|
15
17
|
|
|
16
18
|
violations.sort_by(|left, right| {
|
|
17
19
|
(
|
|
@@ -46,6 +48,76 @@ pub fn validate_scan(scan: ArchitectureScanReport) -> ArchitectureValidation {
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
fn validate_forbidden_layer_dependencies(
|
|
52
|
+
scan: &ArchitectureScanReport,
|
|
53
|
+
violations: &mut Vec<ArchitectureViolation>,
|
|
54
|
+
rules_executed: &mut Vec<String>,
|
|
55
|
+
) {
|
|
56
|
+
let rule = scan.config.rule("no_forbidden_layer_dependencies");
|
|
57
|
+
if !rule.enabled {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
rules_executed.push("arch.no_forbidden_layer_dependencies".to_string());
|
|
61
|
+
for edge in &scan.graph.edges {
|
|
62
|
+
if edge.kind != ArchitectureEdgeKind::Imports {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
let Some(from_path) = edge.from.strip_prefix("file:") else {
|
|
66
|
+
continue;
|
|
67
|
+
};
|
|
68
|
+
let Some(to_path) = edge.to.strip_prefix("file:") else {
|
|
69
|
+
continue;
|
|
70
|
+
};
|
|
71
|
+
let Some(from_fact) = scan.file_facts.get(from_path) else {
|
|
72
|
+
continue;
|
|
73
|
+
};
|
|
74
|
+
let Some(to_fact) = scan.file_facts.get(to_path) else {
|
|
75
|
+
continue;
|
|
76
|
+
};
|
|
77
|
+
if to_fact.layers.is_empty() {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for from_layer in &from_fact.layers {
|
|
81
|
+
let allowed_layers = scan
|
|
82
|
+
.config
|
|
83
|
+
.allowed_dependencies
|
|
84
|
+
.get(from_layer)
|
|
85
|
+
.map(Vec::as_slice)
|
|
86
|
+
.unwrap_or(&[]);
|
|
87
|
+
if to_fact
|
|
88
|
+
.layers
|
|
89
|
+
.iter()
|
|
90
|
+
.any(|to_layer| from_layer == to_layer || allowed_layers.contains(to_layer))
|
|
91
|
+
{
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
let target_layers = to_fact.layers.join(", ");
|
|
95
|
+
violations.push(ArchitectureViolation {
|
|
96
|
+
id: "arch.no_forbidden_layer_dependencies".to_string(),
|
|
97
|
+
severity: rule.severity,
|
|
98
|
+
violation_type: "forbidden_layer_dependency".to_string(),
|
|
99
|
+
message: format!(
|
|
100
|
+
"{} in layer {} imports {} in forbidden layer {}.",
|
|
101
|
+
from_path, from_layer, to_path, target_layers
|
|
102
|
+
),
|
|
103
|
+
from: Some(edge.from.clone()),
|
|
104
|
+
to: Some(edge.to.clone()),
|
|
105
|
+
path: Some(from_path.to_string()),
|
|
106
|
+
source_range: edge.metadata.source_range.clone(),
|
|
107
|
+
suggestion: format!(
|
|
108
|
+
"Move the dependency behind an allowed layer boundary or change naome.arch.yaml allowed_dependencies if this architecture is intentional. {} currently allows: {}.",
|
|
109
|
+
from_layer,
|
|
110
|
+
allowed_layers.join(", ")
|
|
111
|
+
),
|
|
112
|
+
agent_instruction: format!(
|
|
113
|
+
"Do not import {} from {}. Introduce an allowed boundary or invert the dependency before re-running architecture validation.",
|
|
114
|
+
target_layers, from_layer
|
|
115
|
+
),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
49
121
|
fn validate_max_file_lines(
|
|
50
122
|
scan: &ArchitectureScanReport,
|
|
51
123
|
violations: &mut Vec<ArchitectureViolation>,
|
|
@@ -2,7 +2,7 @@ use serde_json::json;
|
|
|
2
2
|
|
|
3
3
|
use crate::architecture::model::{
|
|
4
4
|
ArchitectureEdge, ArchitectureEdgeKind, ArchitectureGraph, ArchitectureMetadata,
|
|
5
|
-
ArchitectureNode, ArchitectureNodeKind,
|
|
5
|
+
ArchitectureNode, ArchitectureNodeKind, SourceRange,
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
pub(super) fn push_node(
|
|
@@ -22,6 +22,32 @@ pub(super) fn push_node(
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
pub(super) fn push_node_with_metadata(
|
|
26
|
+
graph: &mut ArchitectureGraph,
|
|
27
|
+
id: &str,
|
|
28
|
+
kind: ArchitectureNodeKind,
|
|
29
|
+
label: &str,
|
|
30
|
+
path: Option<String>,
|
|
31
|
+
language: Option<String>,
|
|
32
|
+
confidence: f32,
|
|
33
|
+
extractor: &str,
|
|
34
|
+
raw_origin: serde_json::Value,
|
|
35
|
+
) {
|
|
36
|
+
graph.nodes.push(ArchitectureNode {
|
|
37
|
+
id: id.to_string(),
|
|
38
|
+
kind,
|
|
39
|
+
label: label.to_string(),
|
|
40
|
+
metadata: ArchitectureMetadata {
|
|
41
|
+
path,
|
|
42
|
+
language,
|
|
43
|
+
source_range: None,
|
|
44
|
+
confidence,
|
|
45
|
+
extractor: extractor.to_string(),
|
|
46
|
+
raw_origin,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
pub(super) fn push_edge(
|
|
26
52
|
graph: &mut ArchitectureGraph,
|
|
27
53
|
from: &str,
|
|
@@ -40,6 +66,42 @@ pub(super) fn push_edge(
|
|
|
40
66
|
});
|
|
41
67
|
}
|
|
42
68
|
|
|
69
|
+
pub(super) fn push_edge_with_metadata(
|
|
70
|
+
graph: &mut ArchitectureGraph,
|
|
71
|
+
from: &str,
|
|
72
|
+
to: &str,
|
|
73
|
+
kind: ArchitectureEdgeKind,
|
|
74
|
+
label: &str,
|
|
75
|
+
path: Option<String>,
|
|
76
|
+
language: Option<String>,
|
|
77
|
+
source_range: Option<SourceRange>,
|
|
78
|
+
confidence: f32,
|
|
79
|
+
extractor: &str,
|
|
80
|
+
raw_origin: serde_json::Value,
|
|
81
|
+
) {
|
|
82
|
+
graph.edges.push(ArchitectureEdge {
|
|
83
|
+
id: format!(
|
|
84
|
+
"edge:{from}:{to}:{kind:?}:{}",
|
|
85
|
+
raw_origin
|
|
86
|
+
.get("specifier")
|
|
87
|
+
.and_then(serde_json::Value::as_str)
|
|
88
|
+
.unwrap_or(label)
|
|
89
|
+
),
|
|
90
|
+
from: from.to_string(),
|
|
91
|
+
to: to.to_string(),
|
|
92
|
+
kind,
|
|
93
|
+
label: label.to_string(),
|
|
94
|
+
metadata: ArchitectureMetadata {
|
|
95
|
+
path,
|
|
96
|
+
language,
|
|
97
|
+
source_range,
|
|
98
|
+
confidence,
|
|
99
|
+
extractor: extractor.to_string(),
|
|
100
|
+
raw_origin,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
fn metadata(
|
|
44
106
|
path: Option<String>,
|
|
45
107
|
language: Option<String>,
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
use std::collections::BTreeSet;
|
|
2
|
-
use std::fs;
|
|
3
2
|
use std::path::Path;
|
|
4
3
|
|
|
5
4
|
use crate::architecture::config::ArchitectureConfig;
|
|
6
5
|
use crate::architecture::scan::FileFact;
|
|
7
6
|
use crate::paths;
|
|
8
7
|
|
|
9
|
-
pub(super) fn file_fact(
|
|
10
|
-
let content = fs::read_to_string(root.join(path)).unwrap_or_default();
|
|
8
|
+
pub(super) fn file_fact(path: &str, content: &str, config: &ArchitectureConfig) -> FileFact {
|
|
11
9
|
FileFact {
|
|
12
10
|
path: path.to_string(),
|
|
13
11
|
language: language_for_path(path),
|
|
@@ -27,6 +25,7 @@ pub(super) fn file_fact(root: &Path, path: &str, config: &ArchitectureConfig) ->
|
|
|
27
25
|
.map(|(name, value)| (name, &value.paths)),
|
|
28
26
|
),
|
|
29
27
|
ignored: ignore_reason(path, config),
|
|
28
|
+
imports: Vec::new(),
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
use std::collections::{BTreeMap, BTreeSet};
|
|
2
|
+
use std::fs;
|
|
2
3
|
use std::path::Path;
|
|
3
4
|
|
|
4
5
|
use serde_json::json;
|
|
5
6
|
|
|
7
|
+
use super::imports;
|
|
6
8
|
use super::FileFact;
|
|
7
9
|
use crate::architecture::config::ArchitectureConfig;
|
|
8
10
|
use crate::architecture::model::{ArchitectureEdgeKind, ArchitectureGraph, ArchitectureNodeKind};
|
|
@@ -17,16 +19,20 @@ pub(super) fn build_path_graph(
|
|
|
17
19
|
) -> (ArchitectureGraph, BTreeMap<String, FileFact>) {
|
|
18
20
|
let mut graph = ArchitectureGraph::default();
|
|
19
21
|
let mut file_facts = BTreeMap::new();
|
|
22
|
+
let file_set = files.iter().cloned().collect::<BTreeSet<_>>();
|
|
20
23
|
|
|
21
24
|
push_repository_and_policy_nodes(&mut graph, config);
|
|
22
25
|
push_directories(&mut graph, &files);
|
|
23
26
|
|
|
24
27
|
for path in files {
|
|
25
|
-
let
|
|
28
|
+
let content = fs::read_to_string(root.join(&path)).unwrap_or_default();
|
|
29
|
+
let mut fact = facts::file_fact(&path, &content, config);
|
|
30
|
+
fact.imports = imports::extract_imports(root, &path, &content, &file_set);
|
|
26
31
|
push_file(&mut graph, &fact);
|
|
27
32
|
file_facts.insert(path, fact);
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
push_imports(&mut graph, &file_facts);
|
|
30
36
|
(graph, file_facts)
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -115,6 +121,64 @@ fn push_file(graph: &mut ArchitectureGraph, fact: &FileFact) {
|
|
|
115
121
|
push_membership_edges(graph, path, "context", &fact.contexts);
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
fn push_imports(graph: &mut ArchitectureGraph, file_facts: &BTreeMap<String, FileFact>) {
|
|
125
|
+
let mut emitted_target_nodes = BTreeSet::new();
|
|
126
|
+
for fact in file_facts.values() {
|
|
127
|
+
for import in &fact.imports {
|
|
128
|
+
let target_id = match &import.target {
|
|
129
|
+
super::ImportTarget::File(path) => format!("file:{path}"),
|
|
130
|
+
super::ImportTarget::ExternalDependency(name) => {
|
|
131
|
+
let id = format!("external:{name}");
|
|
132
|
+
if emitted_target_nodes.insert(id.clone()) {
|
|
133
|
+
emit::push_node_with_metadata(
|
|
134
|
+
graph,
|
|
135
|
+
&id,
|
|
136
|
+
ArchitectureNodeKind::ExternalDependency,
|
|
137
|
+
name,
|
|
138
|
+
None,
|
|
139
|
+
None,
|
|
140
|
+
import.confidence,
|
|
141
|
+
&import.extractor,
|
|
142
|
+
json!({ "specifier": import.specifier, "resolvedAs": "external" }),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
id
|
|
146
|
+
}
|
|
147
|
+
super::ImportTarget::Unknown(specifier) => {
|
|
148
|
+
let id = format!("unknown-import:{}", stable_fragment(specifier));
|
|
149
|
+
if emitted_target_nodes.insert(id.clone()) {
|
|
150
|
+
emit::push_node_with_metadata(
|
|
151
|
+
graph,
|
|
152
|
+
&id,
|
|
153
|
+
ArchitectureNodeKind::ExternalDependency,
|
|
154
|
+
specifier,
|
|
155
|
+
None,
|
|
156
|
+
None,
|
|
157
|
+
import.confidence,
|
|
158
|
+
&import.extractor,
|
|
159
|
+
json!({ "specifier": specifier, "resolvedAs": "unknown" }),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
id
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
emit::push_edge_with_metadata(
|
|
166
|
+
graph,
|
|
167
|
+
&format!("file:{}", fact.path),
|
|
168
|
+
&target_id,
|
|
169
|
+
ArchitectureEdgeKind::Imports,
|
|
170
|
+
"imports",
|
|
171
|
+
Some(fact.path.clone()),
|
|
172
|
+
fact.language.clone(),
|
|
173
|
+
import.source_range.clone(),
|
|
174
|
+
import.confidence,
|
|
175
|
+
&import.extractor,
|
|
176
|
+
json!({ "specifier": import.specifier, "target": import.target }),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
118
182
|
fn push_membership_edges(
|
|
119
183
|
graph: &mut ArchitectureGraph,
|
|
120
184
|
path: &str,
|
|
@@ -132,3 +196,16 @@ fn push_membership_edges(
|
|
|
132
196
|
);
|
|
133
197
|
}
|
|
134
198
|
}
|
|
199
|
+
|
|
200
|
+
fn stable_fragment(value: &str) -> String {
|
|
201
|
+
value
|
|
202
|
+
.chars()
|
|
203
|
+
.map(|character| {
|
|
204
|
+
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '/') {
|
|
205
|
+
character
|
|
206
|
+
} else {
|
|
207
|
+
'_'
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
.collect()
|
|
211
|
+
}
|