debian_analyzer/
vcs.rs

1//! Information about version control systems.
2use debian_control::vcs::ParsedVcs;
3use log::debug;
4use url::Url;
5
6/// List of known GitLab sites.
7pub const KNOWN_GITLAB_SITES: &[&str] = &["salsa.debian.org", "invent.kde.org", "0xacab.org"];
8
9/// Check if a particular host is a GitLab instance.
10pub fn is_gitlab_site(hostname: &str, net_access: Option<bool>) -> bool {
11    if KNOWN_GITLAB_SITES.contains(&hostname) {
12        return true;
13    }
14
15    if hostname.starts_with("gitlab.") {
16        return true;
17    }
18
19    if net_access.unwrap_or(false) {
20        probe_gitlab_host(hostname)
21    } else {
22        false
23    }
24}
25
26/// Check if a particular host is a GitLab instance.
27pub fn probe_gitlab_host(hostname: &str) -> bool {
28    use reqwest::header::HeaderMap;
29    let url = format!("https://{}/api/v4/version", hostname);
30
31    let mut headers = HeaderMap::new();
32    headers.insert(reqwest::header::ACCEPT, "application/json".parse().unwrap());
33
34    let client = reqwest::blocking::Client::builder()
35        .default_headers(headers)
36        .build()
37        .expect("Failed to build HTTP client");
38
39    let http_url: reqwest::Url = url.parse().expect("Invalid URL format");
40
41    let request = client
42        .get(http_url)
43        .build()
44        .expect("Failed to build request");
45
46    let response = match client.execute(request) {
47        Ok(r) => r,
48        Err(_) => return false,
49    };
50
51    match response.status().as_u16() {
52        401 => {
53            if let Ok(data) = response.json::<serde_json::Value>() {
54                if let Some(message) = data["message"].as_str() {
55                    if message == "401 Unauthorized" {
56                        true
57                    } else {
58                        debug!("failed to parse JSON response: {:?}", data);
59                        false
60                    }
61                } else {
62                    debug!("failed to parse JSON response: {:?}", data);
63                    false
64                }
65            } else {
66                debug!("failed to parse JSON response");
67                false
68            }
69        }
70        200 => true,
71        _ => {
72            debug!("unexpected HTTP status code: {:?}", response.status());
73            false
74        }
75    }
76}
77
78/// Determine the URL of the browser for a GitLab repository.
79pub fn determine_gitlab_browser_url(url: &str) -> Url {
80    let parsed_vcs: ParsedVcs = url.trim_end_matches('/').parse().unwrap();
81
82    // TODO(jelmer): Add support for branches
83    let parsed_url = Url::parse(&parsed_vcs.repo_url).unwrap();
84
85    let path = parsed_url
86        .path()
87        .trim_end_matches('/')
88        .trim_end_matches(".git");
89
90    let branch = if let Some(branch) = parsed_vcs.branch {
91        Some(branch)
92    } else if parsed_vcs.subpath.is_some() {
93        Some("HEAD".to_string())
94    } else {
95        None
96    };
97
98    let mut path = if let Some(branch) = branch {
99        format!("{}/-/tree/{}", path, branch)
100    } else {
101        path.to_string()
102    };
103
104    if let Some(subpath) = parsed_vcs.subpath {
105        path.push_str(&format!("/{}", subpath));
106    }
107
108    let url = format!(
109        "https://{}/{}",
110        parsed_url.host_str().unwrap(),
111        path.trim_start_matches('/')
112    );
113
114    Url::parse(&url).unwrap()
115}
116
117/// Determine the URL of the browser for a VCS repository.
118pub fn determine_browser_url(
119    _vcs_type: &str,
120    vcs_url: &str,
121    net_access: Option<bool>,
122) -> Option<Url> {
123    let parsed_vcs: ParsedVcs = vcs_url.parse().ok()?;
124
125    let parsed_url: Url = parsed_vcs.repo_url.parse().ok()?;
126
127    match parsed_url.host_str()? {
128        host if is_gitlab_site(host, net_access) => Some(determine_gitlab_browser_url(vcs_url)),
129
130        "github.com" => {
131            let path = parsed_url.path().trim_end_matches(".git");
132
133            let branch = if let Some(branch) = parsed_vcs.branch {
134                Some(branch)
135            } else if parsed_vcs.subpath.is_some() {
136                Some("HEAD".to_string())
137            } else {
138                None
139            };
140
141            let mut path = if let Some(branch) = branch {
142                format!("{}/tree/{}", path, branch)
143            } else {
144                path.to_string()
145            };
146
147            if let Some(subpath) = parsed_vcs.subpath {
148                path.push_str(&format!("/{}", subpath));
149            }
150
151            let url = format!(
152                "https://{}/{}",
153                parsed_url.host_str().unwrap(),
154                path.trim_start_matches('/')
155            );
156
157            Some(Url::parse(&url).unwrap())
158        }
159        host if (host == "code.launchpad.net" || host == "launchpad.net")
160            && parsed_vcs.branch.is_none()
161            && parsed_vcs.subpath.is_none() =>
162        {
163            let url = format!(
164                "https://code.launchpad.net/{}",
165                parsed_url.path().trim_start_matches('/')
166            );
167
168            Some(Url::parse(&url).unwrap())
169        }
170        "git.savannah.gnu.org" | "git.sv.gnu.org" => {
171            let mut path_elements = parsed_url.path_segments().unwrap().collect::<Vec<_>>();
172            if parsed_url.scheme() == "https" && path_elements.first() == Some(&"git") {
173                path_elements.remove(0);
174            }
175            // Why cgit and not gitweb?
176            path_elements.insert(0, "cgit");
177            Some(
178                Url::parse(&format!(
179                    "https://{}/{}",
180                    parsed_url.host_str().unwrap(),
181                    path_elements.join("/")
182                ))
183                .unwrap(),
184            )
185        }
186        "git.code.sf.net" | "git.code.sourceforge.net" => {
187            let path_elements = parsed_url.path_segments().unwrap().collect::<Vec<_>>();
188            if path_elements.first() != Some(&"p") {
189                return None;
190            }
191            let project = path_elements[1];
192            let repository = path_elements[2];
193            let mut path_elements = vec!["p", project, repository];
194            let branch = if let Some(branch) = parsed_vcs.branch {
195                Some(branch)
196            } else if parsed_vcs.subpath.is_some() {
197                Some("HEAD".to_string())
198            } else {
199                None
200            };
201
202            if let Some(branch) = branch.as_deref() {
203                path_elements.extend(["ci", branch, "tree"]);
204            }
205
206            if let Some(subpath) = parsed_vcs.subpath.as_ref() {
207                path_elements.push(subpath);
208            }
209
210            let url = format!("https://sourceforge.net/{}", path_elements.join("/"));
211            Some(Url::parse(&url).unwrap())
212        }
213        _ => None,
214    }
215}
216
217/// Canonicalize a VCS browser URL.
218pub fn canonicalize_vcs_browser_url(url: &str) -> String {
219    let url = url.replace(
220        "https://svn.debian.org/wsvn/",
221        "https://anonscm.debian.org/viewvc/",
222    );
223    let url = url.replace(
224        "http://svn.debian.org/wsvn/",
225        "https://anonscm.debian.org/viewvc/",
226    );
227    let url = url.replace(
228        "https://git.debian.org/?p=",
229        "https://anonscm.debian.org/git/",
230    );
231    let url = url.replace(
232        "http://git.debian.org/?p=",
233        "https://anonscm.debian.org/git/",
234    );
235    let url = url.replace(
236        "https://bzr.debian.org/loggerhead/",
237        "https://anonscm.debian.org/loggerhead/",
238    );
239    let url = url.replace(
240        "http://bzr.debian.org/loggerhead/",
241        "https://anonscm.debian.org/loggerhead/",
242    );
243
244    lazy_regex::regex_replace!(
245        r"^https?://salsa.debian.org/([^/]+/[^/]+)\.git/?$",
246        &url,
247        |_, x| "https://salsa.debian.org/".to_string() + x
248    )
249    .into_owned()
250}
251
252/// VCS information for a package.
253#[derive(Debug, PartialEq, Eq, Clone)]
254pub enum PackageVcs {
255    /// Git repository.
256    Git {
257        /// URL of the repository.
258        url: Url,
259
260        /// Branch name.
261        branch: Option<String>,
262
263        /// Subpath within the repository.
264        subpath: Option<std::path::PathBuf>,
265    },
266    /// Subversion repository.
267    Svn(Url),
268
269    /// Bazaar repository.
270    Bzr(Url),
271
272    /// Mercurial repository.
273    Hg {
274        /// URL of the repository.
275        url: Url,
276
277        /// Branch name.
278        branch: Option<String>,
279
280        /// Subpath within the repository.
281        subpath: Option<std::path::PathBuf>,
282    },
283
284    /// Monotone repository.
285    Mtn(Url),
286
287    /// CVS repository.
288    Cvs(String),
289
290    /// Darcs repository.
291    Darcs(Url),
292
293    /// Arch repository.
294    Arch(Url),
295
296    /// Svk repository.
297    Svk(Url),
298}
299
300impl PackageVcs {
301    /// Get the type of the VCS repository as a string.
302    pub fn type_str(&self) -> &str {
303        match self {
304            PackageVcs::Git { .. } => "Git",
305            PackageVcs::Svn(_) => "Svn",
306            PackageVcs::Bzr(_) => "Bzr",
307            PackageVcs::Hg { .. } => "Hg",
308            PackageVcs::Mtn(_) => "Mtn",
309            PackageVcs::Cvs(_) => "Cvs",
310            PackageVcs::Darcs(_) => "Darcs",
311            PackageVcs::Arch(_) => "Arch",
312            PackageVcs::Svk(_) => "Svk",
313        }
314    }
315
316    /// Get the URL of the VCS repository.
317    pub fn url(&self) -> Option<&url::Url> {
318        match self {
319            PackageVcs::Git { url, .. } => Some(url),
320            PackageVcs::Svn(url) => Some(url),
321            PackageVcs::Bzr(url) => Some(url),
322            PackageVcs::Hg { url, .. } => Some(url),
323            PackageVcs::Mtn(url) => Some(url),
324            PackageVcs::Darcs(url) => Some(url),
325            PackageVcs::Arch(url) => Some(url),
326            PackageVcs::Svk(url) => Some(url),
327            PackageVcs::Cvs(_) => None,
328        }
329    }
330
331    /// Get the branch name of the VCS repository.
332    pub fn branch(&self) -> Option<&str> {
333        match self {
334            PackageVcs::Git { branch, .. } => branch.as_deref(),
335            PackageVcs::Hg { branch, .. } => branch.as_deref(),
336            _ => None,
337        }
338    }
339
340    /// Get the subpath of the VCS repository.
341    pub fn subpath(&self) -> Option<&std::path::Path> {
342        match self {
343            PackageVcs::Git { subpath, .. } => subpath.as_deref(),
344            PackageVcs::Hg { subpath, .. } => subpath.as_deref(),
345            _ => None,
346        }
347    }
348
349    /// Get the location of the VCS repository.
350    pub fn location(&self) -> String {
351        match self {
352            PackageVcs::Git {
353                url,
354                branch,
355                subpath,
356            } => {
357                let mut result = url.to_string();
358                if let Some(branch) = branch {
359                    result.push_str(&format!(" -b {}", branch));
360                }
361                if let Some(subpath) = subpath {
362                    result.push_str(&format!(" [{}]", subpath.display()));
363                }
364                result
365            }
366            PackageVcs::Svn(url) => url.to_string(),
367            PackageVcs::Bzr(url) => url.to_string(),
368            PackageVcs::Hg {
369                url,
370                branch,
371                subpath,
372            } => {
373                let mut result = url.to_string();
374                if let Some(branch) = branch {
375                    result.push_str(&format!(" -b {}", branch));
376                }
377                if let Some(subpath) = subpath {
378                    result.push_str(&format!(" [{}]", subpath.display()));
379                }
380                result
381            }
382            PackageVcs::Mtn(url) => url.to_string(),
383            PackageVcs::Cvs(s) => s.clone(),
384            PackageVcs::Darcs(url) => url.to_string(),
385            PackageVcs::Arch(url) => url.to_string(),
386            PackageVcs::Svk(url) => url.to_string(),
387        }
388    }
389}
390
391impl From<PackageVcs> for ParsedVcs {
392    fn from(vcs: PackageVcs) -> Self {
393        match vcs {
394            PackageVcs::Git {
395                url,
396                branch,
397                subpath,
398            } => ParsedVcs {
399                repo_url: url.to_string(),
400                branch,
401                subpath: subpath.map(|x| x.to_string_lossy().to_string()),
402            },
403            PackageVcs::Svn(url) => ParsedVcs {
404                repo_url: url.to_string(),
405                branch: None,
406                subpath: None,
407            },
408            PackageVcs::Bzr(url) => ParsedVcs {
409                repo_url: url.to_string(),
410                branch: None,
411                subpath: None,
412            },
413            PackageVcs::Hg {
414                url,
415                branch,
416                subpath,
417            } => ParsedVcs {
418                repo_url: url.to_string(),
419                branch,
420                subpath: subpath.map(|x| x.to_string_lossy().to_string()),
421            },
422            PackageVcs::Mtn(url) => ParsedVcs {
423                repo_url: url.to_string(),
424                branch: None,
425                subpath: None,
426            },
427            PackageVcs::Cvs(s) => ParsedVcs {
428                repo_url: s,
429                branch: None,
430                subpath: None,
431            },
432            PackageVcs::Darcs(url) => ParsedVcs {
433                repo_url: url.to_string(),
434                branch: None,
435                subpath: None,
436            },
437            PackageVcs::Arch(url) => ParsedVcs {
438                repo_url: url.to_string(),
439                branch: None,
440                subpath: None,
441            },
442            PackageVcs::Svk(url) => ParsedVcs {
443                repo_url: url.to_string(),
444                branch: None,
445                subpath: None,
446            },
447        }
448    }
449}
450
451/// Trait for types that can provide VCS information.
452pub trait VcsSource {
453    /// Get the Vcs-Git field.
454    fn vcs_git(&self) -> Option<String>;
455
456    /// Get the Vcs-Svn field.
457    fn vcs_svn(&self) -> Option<String>;
458
459    /// Get the Vcs-Bzr field.
460    fn vcs_bzr(&self) -> Option<String>;
461
462    /// Get the Vcs-Hg field.
463    fn vcs_hg(&self) -> Option<String>;
464
465    /// Get the Vcs-Mtn field.
466    fn vcs_mtn(&self) -> Option<String>;
467
468    /// Get the Vcs-Cvs field.
469    fn vcs_cvs(&self) -> Option<String>;
470
471    /// Get the Vcs-Darcs field.
472    fn vcs_darcs(&self) -> Option<String>;
473
474    /// Get the Vcs-Arch field.
475    fn vcs_arch(&self) -> Option<String>;
476
477    /// Get the Vcs-Svk field.
478    fn vcs_svk(&self) -> Option<String>;
479}
480
481impl VcsSource for debian_control::Source {
482    fn vcs_git(&self) -> Option<String> {
483        self.vcs_git()
484    }
485
486    fn vcs_svn(&self) -> Option<String> {
487        self.vcs_svn()
488    }
489
490    fn vcs_bzr(&self) -> Option<String> {
491        self.vcs_bzr()
492    }
493
494    fn vcs_hg(&self) -> Option<String> {
495        self.vcs_hg()
496    }
497
498    fn vcs_mtn(&self) -> Option<String> {
499        self.vcs_mtn()
500    }
501
502    fn vcs_cvs(&self) -> Option<String> {
503        self.vcs_cvs()
504    }
505
506    fn vcs_darcs(&self) -> Option<String> {
507        self.vcs_darcs()
508    }
509
510    fn vcs_arch(&self) -> Option<String> {
511        self.vcs_arch()
512    }
513
514    fn vcs_svk(&self) -> Option<String> {
515        self.vcs_svk()
516    }
517}
518
519impl VcsSource for debian_control::apt::Source {
520    fn vcs_git(&self) -> Option<String> {
521        self.vcs_git()
522    }
523
524    fn vcs_svn(&self) -> Option<String> {
525        self.vcs_svn()
526    }
527
528    fn vcs_bzr(&self) -> Option<String> {
529        self.vcs_bzr()
530    }
531
532    fn vcs_hg(&self) -> Option<String> {
533        self.vcs_hg()
534    }
535
536    fn vcs_mtn(&self) -> Option<String> {
537        self.vcs_mtn()
538    }
539
540    fn vcs_cvs(&self) -> Option<String> {
541        self.vcs_cvs()
542    }
543
544    fn vcs_darcs(&self) -> Option<String> {
545        self.vcs_darcs()
546    }
547
548    fn vcs_arch(&self) -> Option<String> {
549        self.vcs_arch()
550    }
551
552    fn vcs_svk(&self) -> Option<String> {
553        self.vcs_svk()
554    }
555}
556
557/// Determine the VCS field for a source package.
558pub fn vcs_field(source_package: &impl VcsSource) -> Option<(String, String)> {
559    if let Some(value) = source_package.vcs_git() {
560        return Some(("Git".to_string(), value));
561    }
562    if let Some(value) = source_package.vcs_svn() {
563        return Some(("Svn".to_string(), value));
564    }
565    if let Some(value) = source_package.vcs_bzr() {
566        return Some(("Bzr".to_string(), value));
567    }
568    if let Some(value) = source_package.vcs_hg() {
569        return Some(("Hg".to_string(), value));
570    }
571    if let Some(value) = source_package.vcs_mtn() {
572        return Some(("Mtn".to_string(), value));
573    }
574    if let Some(value) = source_package.vcs_cvs() {
575        return Some(("Cvs".to_string(), value));
576    }
577    if let Some(value) = source_package.vcs_darcs() {
578        return Some(("Darcs".to_string(), value));
579    }
580    if let Some(value) = source_package.vcs_arch() {
581        return Some(("Arch".to_string(), value));
582    }
583    if let Some(value) = source_package.vcs_svk() {
584        return Some(("Svk".to_string(), value));
585    }
586    None
587}
588
589/// Determine the VCS URL for a source package.
590pub fn source_package_vcs(source_package: &impl VcsSource) -> Option<PackageVcs> {
591    if let Some(value) = source_package.vcs_git() {
592        let parsed_vcs: ParsedVcs = value.parse().unwrap();
593        let url = parsed_vcs.repo_url.parse().unwrap();
594        return Some(PackageVcs::Git {
595            url,
596            branch: parsed_vcs.branch,
597            subpath: parsed_vcs.subpath.map(std::path::PathBuf::from),
598        });
599    }
600    if let Some(value) = source_package.vcs_svn() {
601        let url = value.parse().unwrap();
602        return Some(PackageVcs::Svn(url));
603    }
604    if let Some(value) = source_package.vcs_bzr() {
605        let url = value.parse().unwrap();
606        return Some(PackageVcs::Bzr(url));
607    }
608    if let Some(value) = source_package.vcs_hg() {
609        let parsed_vcs: ParsedVcs = value.parse().unwrap();
610        let url = parsed_vcs.repo_url.parse().unwrap();
611        return Some(PackageVcs::Hg {
612            url,
613            branch: parsed_vcs.branch,
614            subpath: parsed_vcs.subpath.map(std::path::PathBuf::from),
615        });
616    }
617    if let Some(value) = source_package.vcs_mtn() {
618        let url = value.parse().unwrap();
619        return Some(PackageVcs::Mtn(url));
620    }
621    if let Some(value) = source_package.vcs_cvs() {
622        return Some(PackageVcs::Cvs(value.clone()));
623    }
624    if let Some(value) = source_package.vcs_darcs() {
625        let url = value.parse().unwrap();
626        return Some(PackageVcs::Darcs(url));
627    }
628    if let Some(value) = source_package.vcs_arch() {
629        let url = value.parse().unwrap();
630        return Some(PackageVcs::Arch(url));
631    }
632    if let Some(value) = source_package.vcs_svk() {
633        let url = value.parse().unwrap();
634        return Some(PackageVcs::Svk(url));
635    }
636    None
637}
638
639/// Error type for GBP tag format expansion
640#[derive(Debug, Clone, PartialEq, Eq)]
641pub struct GbpTagFormatError {
642    /// The tag format string that caused the error
643    pub tag_name: String,
644    /// The unknown variable name
645    pub variable: String,
646}
647
648impl std::fmt::Display for GbpTagFormatError {
649    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
650        write!(
651            f,
652            "Unknown variable '{}' in gbp tag name '{}'",
653            self.variable, self.tag_name
654        )
655    }
656}
657
658impl std::error::Error for GbpTagFormatError {}
659
660/// Expand a gbp-buildpackage tag format string.
661///
662/// Substitutes variables in a tag format string following gbp conventions.
663/// Supports:
664/// - `%(version)s` - The version string
665/// - `%(hversion)s` - The version with dots replaced by dashes
666/// - `%(version%M%R)s` - Version with character M replaced by R
667///
668/// # Arguments
669/// * `tag_format` - Tag format string (e.g., "debian/%(version)s")
670/// * `version` - Version to substitute
671///
672/// # Returns
673/// Expanded tag name or error if unknown variable found
674///
675/// # Examples
676/// ```rust
677/// use debian_analyzer::vcs::gbp_expand_tag_name;
678///
679/// assert_eq!(
680///     gbp_expand_tag_name("debian/%(version)s", "1.0-1").unwrap(),
681///     "debian/1.0-1"
682/// );
683/// assert_eq!(
684///     gbp_expand_tag_name("v%(hversion)s", "1.0-1").unwrap(),
685///     "v1-0-1"
686/// );
687/// assert_eq!(
688///     gbp_expand_tag_name("%(version%~%_)s", "1.0~rc1").unwrap(),
689///     "1.0_rc1"
690/// );
691/// ```
692pub fn gbp_expand_tag_name(tag_format: &str, version: &str) -> Result<String, GbpTagFormatError> {
693    // Handle version mangling: %(version%M%R)s where M is the match character and R is replacement
694    // The R part can contain escaped % as \%
695    let version_mangle_re: &'static lazy_regex::Lazy<lazy_regex::Regex> =
696        lazy_regex::regex!(r"%\(version%(?P<M>.)%(?P<R>([^%]|\\%)+)\)s");
697
698    let (ret, mangled_version) = if let Some(captures) = version_mangle_re.captures(tag_format) {
699        let match_char = captures.name("M").unwrap().as_str();
700        let replacement = captures.name("R").unwrap().as_str().replace(r"\%", "%");
701        let mangled = version.replace(match_char, &replacement);
702        let simplified = version_mangle_re.replace(tag_format, "%(version)s");
703        (simplified.to_string(), mangled)
704    } else {
705        (tag_format.to_string(), version.to_string())
706    };
707
708    // Substitute known variables
709    let hversion = mangled_version.replace('.', "-");
710
711    let result = ret
712        .replace("%(version)s", &mangled_version)
713        .replace("%(hversion)s", &hversion);
714
715    // Check for unknown variables
716    let unknown_var_re: &'static lazy_regex::Lazy<lazy_regex::Regex> =
717        lazy_regex::regex!(r"%\((\w+)\)s");
718    if let Some(captures) = unknown_var_re.captures(&result) {
719        return Err(GbpTagFormatError {
720            tag_name: tag_format.to_string(),
721            variable: captures.get(1).unwrap().as_str().to_string(),
722        });
723    }
724
725    Ok(result)
726}
727
728#[cfg(test)]
729mod tests {
730    #[test]
731    fn test_source_package_vcs() {
732        use super::PackageVcs;
733        use debian_control::Control;
734
735        let control: Control = r#"Source: foo
736Vcs-Git: https://salsa.debian.org/foo/bar.git
737"#
738        .parse()
739        .unwrap();
740        assert_eq!(
741            super::source_package_vcs(&control.source().unwrap()),
742            Some(PackageVcs::Git {
743                url: "https://salsa.debian.org/foo/bar.git".parse().unwrap(),
744                branch: None,
745                subpath: None
746            })
747        );
748
749        let control: Control = r#"Source: foo
750Vcs-Svn: https://svn.debian.org/svn/foo/bar
751"#
752        .parse()
753        .unwrap();
754        assert_eq!(
755            super::source_package_vcs(&control.source().unwrap()),
756            Some(PackageVcs::Svn(
757                "https://svn.debian.org/svn/foo/bar".parse().unwrap()
758            ))
759        );
760    }
761
762    #[test]
763    fn test_determine_gitlab_browser_url() {
764        use super::determine_gitlab_browser_url;
765
766        assert_eq!(
767            determine_gitlab_browser_url("https://salsa.debian.org/foo/bar"),
768            "https://salsa.debian.org/foo/bar".parse().unwrap()
769        );
770
771        assert_eq!(
772            determine_gitlab_browser_url("https://salsa.debian.org/foo/bar.git"),
773            "https://salsa.debian.org/foo/bar".parse().unwrap()
774        );
775
776        assert_eq!(
777            determine_gitlab_browser_url("https://salsa.debian.org/foo/bar/"),
778            "https://salsa.debian.org/foo/bar".parse().unwrap()
779        );
780
781        assert_eq!(
782            determine_gitlab_browser_url("https://salsa.debian.org/foo/bar/.git"),
783            "https://salsa.debian.org/foo/bar/".parse().unwrap()
784        );
785
786        assert_eq!(
787            determine_gitlab_browser_url("https://salsa.debian.org/foo/bar.git -b baz"),
788            "https://salsa.debian.org/foo/bar/-/tree/baz"
789                .parse()
790                .unwrap()
791        );
792
793        assert_eq!(
794            determine_gitlab_browser_url(
795                "https://salsa.debian.org/foo/bar.git/ -b baz [otherpath]"
796            ),
797            "https://salsa.debian.org/foo/bar/-/tree/baz/otherpath"
798                .parse()
799                .unwrap()
800        );
801    }
802
803    #[test]
804    fn test_determine_browser_url() {
805        use super::determine_browser_url;
806        use url::Url;
807
808        assert_eq!(
809            determine_browser_url("git", "https://salsa.debian.org/foo/bar", Some(false)),
810            Some(Url::parse("https://salsa.debian.org/foo/bar").unwrap())
811        );
812        assert_eq!(
813            determine_browser_url("git", "https://salsa.debian.org/foo/bar.git", Some(false)),
814            Some(Url::parse("https://salsa.debian.org/foo/bar").unwrap())
815        );
816        assert_eq!(
817            determine_browser_url("git", "https://salsa.debian.org/foo/bar/", Some(false)),
818            Some(Url::parse("https://salsa.debian.org/foo/bar").unwrap())
819        );
820        assert_eq!(
821            determine_browser_url("git", "https://salsa.debian.org/foo/bar/.git", Some(false)),
822            Some(Url::parse("https://salsa.debian.org/foo/bar/").unwrap())
823        );
824        assert_eq!(
825            determine_browser_url("git", "https://salsa.debian.org/foo/bar.git/", Some(false)),
826            Some(Url::parse("https://salsa.debian.org/foo/bar").unwrap())
827        );
828        assert_eq!(
829            determine_browser_url(
830                "git",
831                "https://salsa.debian.org/foo/bar.git/.git",
832                Some(false)
833            ),
834            Some(Url::parse("https://salsa.debian.org/foo/bar.git/").unwrap())
835        );
836        assert_eq!(
837            determine_browser_url(
838                "git",
839                "https://salsa.debian.org/foo/bar.git.git",
840                Some(false)
841            ),
842            Some(Url::parse("https://salsa.debian.org/foo/bar").unwrap())
843        );
844        assert_eq!(
845            determine_browser_url(
846                "git",
847                "https://salsa.debian.org/foo/bar.git.git/",
848                Some(false)
849            ),
850            Some(Url::parse("https://salsa.debian.org/foo/bar").unwrap())
851        );
852
853        assert_eq!(
854            Some(Url::parse("https://salsa.debian.org/jelmer/dulwich").unwrap()),
855            determine_browser_url(
856                "git",
857                "https://salsa.debian.org/jelmer/dulwich.git",
858                Some(false)
859            )
860        );
861
862        assert_eq!(
863            Some(Url::parse("https://github.com/jelmer/dulwich").unwrap()),
864            determine_browser_url("git", "https://github.com/jelmer/dulwich.git", Some(false))
865        );
866        assert_eq!(
867            Some(Url::parse("https://github.com/jelmer/dulwich/tree/master").unwrap()),
868            determine_browser_url(
869                "git",
870                "https://github.com/jelmer/dulwich.git -b master",
871                Some(false)
872            )
873        );
874        assert_eq!(
875            Some(Url::parse("https://github.com/jelmer/dulwich/tree/master").unwrap()),
876            determine_browser_url(
877                "git",
878                "git://github.com/jelmer/dulwich -b master",
879                Some(false)
880            ),
881        );
882        assert_eq!(
883            Some(Url::parse("https://github.com/jelmer/dulwich/tree/master/blah").unwrap()),
884            determine_browser_url(
885                "git",
886                "git://github.com/jelmer/dulwich -b master [blah]",
887                Some(false)
888            ),
889        );
890        assert_eq!(
891            Some(Url::parse("https://github.com/jelmer/dulwich/tree/HEAD/blah").unwrap()),
892            determine_browser_url("git", "git://github.com/jelmer/dulwich [blah]", Some(false)),
893        );
894        assert_eq!(
895            Some(Url::parse("https://git.sv.gnu.org/cgit/rcs.git").unwrap()),
896            determine_browser_url("git", "https://git.sv.gnu.org/git/rcs.git", Some(false)),
897        );
898        assert_eq!(
899            Some(Url::parse("https://git.savannah.gnu.org/cgit/rcs.git").unwrap()),
900            determine_browser_url("git", "git://git.savannah.gnu.org/rcs.git", Some(false)),
901        );
902        assert_eq!(
903            Some(Url::parse("https://sourceforge.net/p/shorewall/debian").unwrap()),
904            determine_browser_url(
905                "git",
906                "git://git.code.sf.net/p/shorewall/debian",
907                Some(false)
908            ),
909        );
910        assert_eq!(
911            Some(Url::parse("https://sourceforge.net/p/shorewall/debian/ci/foo/tree").unwrap()),
912            determine_browser_url(
913                "git",
914                "git://git.code.sf.net/p/shorewall/debian -b foo",
915                Some(false)
916            ),
917        );
918        assert_eq!(
919            Some(Url::parse("https://sourceforge.net/p/shorewall/debian/ci/HEAD/tree/sp").unwrap()),
920            determine_browser_url(
921                "git",
922                "git://git.code.sf.net/p/shorewall/debian [sp]",
923                Some(false)
924            ),
925        );
926        assert_eq!(
927            Some(Url::parse("https://sourceforge.net/p/shorewall/debian/ci/foo/tree/sp").unwrap()),
928            determine_browser_url(
929                "git",
930                "git://git.code.sf.net/p/shorewall/debian -b foo [sp]",
931                Some(false)
932            ),
933        );
934    }
935
936    #[test]
937    fn test_vcs_field() {
938        use debian_control::Control;
939
940        let control: Control = r#"Source: foo
941Vcs-Git: https://salsa.debian.org/foo/bar.git
942"#
943        .parse()
944        .unwrap();
945        assert_eq!(
946            super::vcs_field(&control.source().unwrap()),
947            Some((
948                "Git".to_string(),
949                "https://salsa.debian.org/foo/bar.git".to_string()
950            ))
951        );
952    }
953
954    #[test]
955    fn test_determine_browser_url_invalid_inputs() {
956        use super::determine_browser_url;
957
958        // Test with invalid VCS URL that can't be parsed
959        assert_eq!(
960            determine_browser_url("git", "not a valid vcs url", Some(false)),
961            None
962        );
963
964        // Test with empty string
965        assert_eq!(determine_browser_url("git", "", Some(false)), None);
966
967        // Test with URL that has no host
968        assert_eq!(
969            determine_browser_url("git", "file:///path/to/repo", Some(false)),
970            None
971        );
972
973        // Test with malformed URL in VCS string
974        assert_eq!(
975            determine_browser_url("git", "://missing-scheme", Some(false)),
976            None
977        );
978
979        // Test with VCS string that contains invalid URL characters
980        assert_eq!(
981            determine_browser_url("git", "http://[invalid brackets", Some(false)),
982            None
983        );
984
985        // Test with VCS string containing spaces but not in proper format
986        assert_eq!(
987            determine_browser_url("git", "http://example.com/repo with spaces", Some(false)),
988            None
989        );
990    }
991
992    #[test]
993    fn test_determine_browser_url_edge_cases() {
994        use super::determine_browser_url;
995        use url::Url;
996
997        // Test with localhost - should work but return None as it's not a known host
998        assert_eq!(
999            determine_browser_url("git", "http://localhost/repo.git", Some(false)),
1000            None
1001        );
1002
1003        // Test with IP address - should work but return None as it's not a known host
1004        assert_eq!(
1005            determine_browser_url("git", "http://192.168.1.1/repo.git", Some(false)),
1006            None
1007        );
1008
1009        // Test with port number - should work for known hosts
1010        assert_eq!(
1011            determine_browser_url("git", "https://github.com:443/user/repo.git", Some(false)),
1012            Some(Url::parse("https://github.com:443/user/repo").unwrap())
1013        );
1014    }
1015
1016    #[test]
1017    fn test_gbp_expand_tag_name() {
1018        use super::gbp_expand_tag_name;
1019
1020        // Basic version substitution
1021        assert_eq!(
1022            gbp_expand_tag_name("debian/%(version)s", "1.0-1").unwrap(),
1023            "debian/1.0-1"
1024        );
1025
1026        // hversion substitution (dots to dashes)
1027        assert_eq!(
1028            gbp_expand_tag_name("v%(hversion)s", "1.0-1").unwrap(),
1029            "v1-0-1"
1030        );
1031
1032        // Version mangling with tilde to underscore
1033        assert_eq!(
1034            gbp_expand_tag_name("%(version%~%_)s", "1.0~rc1").unwrap(),
1035            "1.0_rc1"
1036        );
1037
1038        // Version mangling with colon to percent
1039        assert_eq!(
1040            gbp_expand_tag_name("%(version%:%-)s", "1:2.0-1").unwrap(),
1041            "1-2.0-1"
1042        );
1043
1044        // Combined version and hversion
1045        assert_eq!(
1046            gbp_expand_tag_name("%(version)s-%(hversion)s", "1.0").unwrap(),
1047            "1.0-1-0"
1048        );
1049
1050        // No substitution needed
1051        assert_eq!(
1052            gbp_expand_tag_name("upstream/1.0", "1.0").unwrap(),
1053            "upstream/1.0"
1054        );
1055
1056        // Unknown variable should error
1057        let result = gbp_expand_tag_name("%(unknown)s", "1.0");
1058        assert!(result.is_err());
1059        assert_eq!(result.unwrap_err().variable, "unknown");
1060    }
1061}