-
Notifications
You must be signed in to change notification settings - Fork 72
feat: 新增 open 指令并增加相应的单元测试和集成测试 #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| use std::process::Command; | ||
|
|
||
| use clap::Parser; | ||
| use lazy_static::lazy_static; | ||
| use regex::Regex; | ||
|
|
||
| use crate::internal::config::Config; | ||
|
|
||
| #[derive(Parser, Debug)] | ||
| pub struct OpenArgs { | ||
| /// The remote to open | ||
| pub remote: Option<String>, | ||
| } | ||
|
|
||
| lazy_static! { | ||
| static ref SCP_RE: Regex = Regex::new(r"^git@([^:]+):(.+?)(\.git)?$").expect("Invalid Regex"); | ||
| static ref SSH_RE: Regex = | ||
| Regex::new(r"^ssh://(?:[^@]+@)?([^:/]+)(?::\d+)?/(.+?)(\.git)?$").expect("Invalid Regex"); | ||
| } | ||
|
Comment on lines
+15
to
+19
|
||
|
|
||
| pub async fn execute(args: OpenArgs) { | ||
| let in_repo = crate::utils::util::check_repo_exist(); | ||
|
|
||
| let remote_url = if let Some(input) = args.remote { | ||
| if in_repo { | ||
| let remotes = Config::all_remote_configs().await; | ||
| if remotes.iter().any(|r| r.name == input) { | ||
| Config::get_remote_url(&input).await | ||
Tyooughtul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } else { | ||
| // If not found in remotes, treat input as the URL directly | ||
| input | ||
| } | ||
| } else { | ||
| // Not in repo, treat input as URL directly | ||
| input | ||
| } | ||
| } else { | ||
| if !in_repo { | ||
| eprintln!("fatal: not a libra repository (or any of the parent directories): .libra"); | ||
| return; | ||
| } | ||
| match Config::get_current_remote_url().await { | ||
| Some(url) => url, | ||
|
Comment on lines
+42
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| None => { | ||
|
Comment on lines
+42
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When no remote is provided, this path calls Useful? React with 👍 / 👎. |
||
| // Fallback, try origin | ||
| let remotes = Config::all_remote_configs().await; | ||
| if let Some(origin) = remotes.iter().find(|r| r.name == "origin") { | ||
| origin.url.clone() | ||
| } else if let Some(first) = remotes.first() { | ||
| first.url.clone() // Fallback to first available remote | ||
| } else { | ||
| eprintln!("fatal: No remote configured"); | ||
| return; | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+42
to
+56
|
||
| }; | ||
|
|
||
| let url = transform_url(&remote_url); | ||
|
|
||
| if !is_safe_url(&url) { | ||
| eprintln!( | ||
| "fatal: calculated URL '{}' is unsafe or invalid. Only http/https are supported.", | ||
| url | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| println!("Opening {}", url); | ||
|
|
||
| if let Err(e) = open_browser(&url) { | ||
| eprintln!("error: failed to open browser: {}", e); | ||
| } | ||
| } | ||
|
|
||
| fn open_browser(url: &str) -> std::io::Result<()> { | ||
| #[cfg(target_os = "windows")] | ||
| { | ||
| Command::new("cmd").args(["/C", "start", "", url]).spawn()?; | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| { | ||
| Command::new("open").arg(url).spawn()?; | ||
| } | ||
| #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] | ||
| { | ||
| Command::new("xdg-open").arg(url).spawn()?; | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn is_safe_url(url: &str) -> bool { | ||
| // Validates that the URL uses http or https scheme. | ||
| // This blocks local file access, javascript:, or other potential injection vectors | ||
| match url::Url::parse(url) { | ||
| Ok(parsed) => parsed.scheme() == "http" || parsed.scheme() == "https", | ||
| Err(_) => false, | ||
| } | ||
| } | ||
|
Comment on lines
78
to
99
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Error handling: Silent failures All three platform-specific commands use
Recommendation: let result = Command::new("open").arg(&url).spawn();
match result {
Ok(_) => {},
Err(e) => eprintln!("warning: Failed to open browser: {}", e),
} |
||
|
|
||
| fn transform_url(remote: &str) -> String { | ||
| if remote.starts_with("http://") || remote.starts_with("https://") { | ||
| return remote.trim_end_matches(".git").to_string(); | ||
| } | ||
|
|
||
| // Handle SCP-like syntax: git@github.com:user/repo.git | ||
| if let Some(caps) = SCP_RE.captures(remote) { | ||
| let host = &caps[1]; | ||
| let path = &caps[2]; | ||
| return format!("https://{}/{}", host, path); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Code clarity: Direct string formatting Minor: The format! macro can be simplified when concatenating with return format!("https://{host}/{path}");This is more idiomatic Rust. |
||
| } | ||
|
|
||
| // Handle ssh:// syntax | ||
| // ssh://[user@]host.xz[:port]/path/to/repo.git/ | ||
| if let Some(caps) = SSH_RE.captures(remote) { | ||
| let host = &caps[1]; | ||
| let path = &caps[2]; | ||
| return format!("https://{}/{}", host, path); | ||
| } | ||
|
|
||
| // Fallback: return as is, maybe it is already workable or user has weird config | ||
| remote.to_string() | ||
| } | ||
|
|
||
| // Unit test | ||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn test_transform_url() { | ||
| assert_eq!( | ||
| transform_url("git@github.com:rust-lang/rust.git"), | ||
| "https://github.com/rust-lang/rust" | ||
| ); | ||
| assert_eq!( | ||
| transform_url("git@gitlab.com:group/project.git"), | ||
| "https://gitlab.com/group/project" | ||
| ); | ||
| assert_eq!( | ||
| transform_url("https://github.com/rust-lang/rust.git"), | ||
| "https://github.com/rust-lang/rust" | ||
| ); | ||
| assert_eq!( | ||
| transform_url("ssh://git@github.com/rust-lang/rust.git"), | ||
| "https://github.com/rust-lang/rust" | ||
| ); | ||
| assert_eq!( | ||
| transform_url("ssh://user@host.com:2222/repo.git"), | ||
| "https://host.com/repo" | ||
| ); | ||
| } | ||
|
Comment on lines
+131
to
+152
|
||
|
|
||
| #[test] | ||
| fn test_is_safe_url() { | ||
| assert!(is_safe_url("https://github.com/rust-lang/rust")); | ||
| assert!(is_safe_url("http://github.com/rust-lang/rust")); | ||
| assert!(!is_safe_url("file:///etc/passwd")); | ||
| assert!(!is_safe_url("javascript:alert(1)")); | ||
| assert!(!is_safe_url("ftp://github.com/rust-lang/rust")); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| //! Tests open command integration to ensure it finds remote correctly. | ||
| use libra::{ | ||
| command::{ | ||
| open, | ||
| remote::{self, RemoteCmds}, | ||
| }, | ||
| utils::test, | ||
| }; | ||
| use serial_test::serial; | ||
| use tempfile::tempdir; | ||
|
|
||
| #[tokio::test] | ||
| #[serial] | ||
| async fn test_open_remote_origin() { | ||
| let repo_dir = tempdir().unwrap(); | ||
| test::setup_with_new_libra_in(repo_dir.path()).await; | ||
| let _guard = test::ChangeDirGuard::new(repo_dir.path()); | ||
|
|
||
| // Add origin remote | ||
| remote::execute(RemoteCmds::Add { | ||
| name: "origin".into(), | ||
| url: "git@github.com:user/repo.git".into(), | ||
| }) | ||
| .await; | ||
|
|
||
| // Test explicit remote | ||
| open::execute(open::OpenArgs { | ||
| remote: Some("origin".to_string()), | ||
| }) | ||
| .await; | ||
|
|
||
| // Test default remote should find origin | ||
| open::execute(open::OpenArgs { remote: None }).await; | ||
|
|
||
| // Test non-existent remote | ||
| open::execute(open::OpenArgs { | ||
| remote: Some("nonexistent".to_string()), | ||
| }) | ||
| .await; | ||
| } | ||
|
Comment on lines
14
to
40
|
||
|
|
||
| #[tokio::test] | ||
| #[serial] | ||
| async fn test_open_no_remote() { | ||
| let repo_dir = tempdir().unwrap(); | ||
| test::setup_with_new_libra_in(repo_dir.path()).await; | ||
| let _guard = test::ChangeDirGuard::new(repo_dir.path()); | ||
|
|
||
| // Should handle no remote configured | ||
| open::execute(open::OpenArgs { remote: None }).await; | ||
| } | ||
|
Comment on lines
12
to
51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test coverage: Limited assertions The test doesn't verify:
Recommendation: #[tokio::test]
#[serial]
async fn test_open_nonexistent_remote() {
let repo_dir = tempdir().unwrap();
test::setup_with_new_libra_in(repo_dir.path()).await;
let _guard = test::ChangeDirGuard::new(repo_dir.path());
// Should print error message and not panic
open::open(open::OpenArgs {
remote: Some("nonexistent".to_string()),
})
.await;
}
#[tokio::test]
#[serial]
async fn test_open_no_remotes_configured() {
let repo_dir = tempdir().unwrap();
test::setup_with_new_libra_in(repo_dir.path()).await;
let _guard = test::ChangeDirGuard::new(repo_dir.path());
// Should print error message and not panic
open::open(open::OpenArgs { remote: None }).await;
}
Comment on lines
44
to
51
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing module-level documentation. All other command modules in this codebase include a //! doc comment at the top describing the module's purpose (see add.rs, blame.rs, branch.rs, etc.). Add a module-level doc comment following the established convention, e.g., "//! Opens the remote repository URL in the default browser by transforming Git URLs to HTTPS format."