1use crate::build_spec::{DepMode, ModuleSpec};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ReplaceDirective {
17 pub old_path: String,
18 pub old_version: Option<String>,
19 pub new_path: String,
20 pub new_version: Option<String>,
21}
22
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
25pub struct GoMod {
26 pub module_path: String,
27 pub go_version: String,
28 pub toolchain: Option<String>,
29 pub requires: Vec<(String, String)>,
31 pub replaces: Vec<ReplaceDirective>,
32}
33
34impl GoMod {
35 #[must_use]
38 pub fn parse(text: &str) -> Self {
39 let mut m = GoMod::default();
40 let mut block: Option<Block> = None;
43
44 for raw in text.lines() {
45 let line = strip_comment(raw).trim();
46 if line.is_empty() {
47 continue;
48 }
49
50 if let Some(_b) = block {
52 if line == ")" {
53 block = None;
54 continue;
55 }
56 }
57
58 match block {
59 Some(Block::Require) => {
60 if let Some((p, v)) = parse_require_entry(line) {
61 m.requires.push((p, v));
62 }
63 continue;
64 }
65 Some(Block::Replace) => {
66 if let Some(r) = parse_replace_entry(line) {
67 m.replaces.push(r);
68 }
69 continue;
70 }
71 None => {}
72 }
73
74 if let Some(rest) = line.strip_prefix("module ") {
76 m.module_path = rest.trim().to_string();
77 } else if let Some(rest) = line.strip_prefix("go ") {
78 m.go_version = rest.trim().to_string();
79 } else if let Some(rest) = line.strip_prefix("toolchain ") {
80 m.toolchain = Some(rest.trim().to_string());
81 } else if line == "require (" || line == "require(" {
82 block = Some(Block::Require);
83 } else if let Some(rest) = line.strip_prefix("require ") {
84 if let Some((p, v)) = parse_require_entry(rest.trim()) {
85 m.requires.push((p, v));
86 }
87 } else if line == "replace (" || line == "replace(" {
88 block = Some(Block::Replace);
89 } else if let Some(rest) = line.strip_prefix("replace ") {
90 if let Some(r) = parse_replace_entry(rest.trim()) {
91 m.replaces.push(r);
92 }
93 }
94 }
96
97 m
98 }
99
100 #[must_use]
104 pub fn to_module_spec(&self, dep_mode: DepMode, vendor_hash: Option<String>) -> ModuleSpec {
105 ModuleSpec {
106 module_path: self.module_path.clone(),
107 go_version: self.go_version.clone(),
108 toolchain: self.toolchain.clone(),
109 has_external_deps: !self.requires.is_empty(),
110 dep_mode,
111 vendor_hash,
112 }
113 }
114}
115
116#[derive(Clone, Copy)]
117enum Block {
118 Require,
119 Replace,
120}
121
122fn strip_comment(line: &str) -> &str {
125 match line.find("//") {
126 Some(i) => &line[..i],
127 None => line,
128 }
129}
130
131fn parse_require_entry(s: &str) -> Option<(String, String)> {
133 let mut it = s.split_whitespace();
134 let path = it.next()?.to_string();
135 let version = it.next()?.to_string();
136 Some((path, version))
137}
138
139fn parse_replace_entry(s: &str) -> Option<ReplaceDirective> {
141 let (lhs, rhs) = s.split_once("=>")?;
142 let (old_path, old_version) = split_path_version(lhs.trim());
143 let (new_path, new_version) = split_path_version(rhs.trim());
144 if old_path.is_empty() || new_path.is_empty() {
145 return None;
146 }
147 Some(ReplaceDirective { old_path, old_version, new_path, new_version })
148}
149
150fn split_path_version(s: &str) -> (String, Option<String>) {
153 let mut it = s.split_whitespace();
154 let path = it.next().unwrap_or_default().to_string();
155 let version = it.next().map(str::to_string);
156 (path, version)
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 const AKEYLESS_SHAPED: &str = r#"
164module akeyless.io/akeyless-main-repo
165
166go 1.26
167
168toolchain go1.26.4
169
170require (
171 github.com/foo/bar v1.2.3
172 github.com/baz/qux v0.4.0 // indirect
173)
174
175require github.com/single/dep v2.0.0
176
177replace github.com/akeylesslabs/akeyless-go/v3 => ./go/src/client/sdktest/akeyless-go
178
179replace (
180 github.com/old/mod v1.0.0 => github.com/new/mod v1.1.0
181)
182"#;
183
184 #[test]
185 fn parses_akeyless_shaped_go_mod() {
186 let m = GoMod::parse(AKEYLESS_SHAPED);
187 assert_eq!(m.module_path, "akeyless.io/akeyless-main-repo");
188 assert_eq!(m.go_version, "1.26");
189 assert_eq!(m.toolchain.as_deref(), Some("go1.26.4"));
190 assert_eq!(m.requires.len(), 3);
191 assert!(m.requires.contains(&("github.com/single/dep".into(), "v2.0.0".into())));
192 let fs_replace = m
194 .replaces
195 .iter()
196 .find(|r| r.old_path == "github.com/akeylesslabs/akeyless-go/v3")
197 .expect("in-tree replace present");
198 assert_eq!(fs_replace.new_path, "./go/src/client/sdktest/akeyless-go");
199 assert_eq!(fs_replace.new_version, None);
200 let mod_replace = m.replaces.iter().find(|r| r.old_path == "github.com/old/mod").unwrap();
202 assert_eq!(mod_replace.old_version.as_deref(), Some("v1.0.0"));
203 assert_eq!(mod_replace.new_version.as_deref(), Some("v1.1.0"));
204 }
205
206 #[test]
207 fn dep_free_module_has_no_external_deps() {
208 let m = GoMod::parse("module example.com/x\n\ngo 1.25\n");
209 assert!(m.requires.is_empty());
210 let spec = m.to_module_spec(DepMode::Vendored, None);
211 assert!(!spec.has_external_deps);
212 assert_eq!(spec.module_path, "example.com/x");
213 assert_eq!(spec.go_version, "1.25");
214 assert!(spec.toolchain.is_none());
215 }
216
217 #[test]
218 fn to_module_spec_carries_dep_mode_and_require_presence() {
219 let m = GoMod::parse(AKEYLESS_SHAPED);
220 let spec = m.to_module_spec(DepMode::Vendored, None);
221 assert!(spec.has_external_deps);
222 assert!(spec.dep_mode.is_vendored());
223 assert!(spec.vendor_hash.is_none());
224 }
225}