Skip to main content

yghfl/
main.rs

1//! Copy GitHub URL (file/line/col) for the current Helix buffer to clipboard.
2//!
3//! # Errors
4//! - `WezTerm`, status line parsing, or Git operations fail.
5
6use core::str::FromStr;
7use std::path::Component;
8use std::path::Path;
9use std::path::PathBuf;
10use std::process::Command;
11use std::sync::Arc;
12
13use rootcause::prelude::ResultExt;
14use rootcause::report;
15use url::Url;
16use ytil_editor::Editor;
17use ytil_hx::HxCursorPosition;
18use ytil_hx::HxStatusLine;
19use ytil_sys::cli::Args;
20use ytil_wezterm::WeztermPane;
21
22/// Copy GitHub URL (file/line/col) for the current Helix buffer to clipboard.
23#[ytil_sys::main]
24fn main() -> rootcause::Result<()> {
25    let args = ytil_sys::cli::get();
26    if args.has_help() {
27        println!("{}", include_str!("../help.txt"));
28        return Ok(());
29    }
30
31    let hx_pane = ytil_wezterm::get_sibling_pane_with_titles(
32        &ytil_wezterm::get_all_panes(&[])?,
33        ytil_wezterm::get_current_pane_id()?,
34        Editor::Hx.pane_titles(),
35    )?;
36
37    let wezterm_pane_text = String::from_utf8(
38        Command::new("wezterm")
39            .args(["cli", "get-text", "--pane-id", &hx_pane.pane_id.to_string()])
40            .output()?
41            .stdout,
42    )?;
43
44    let hx_status_line = HxStatusLine::from_str(
45        wezterm_pane_text
46            .lines()
47            .nth_back(1)
48            .ok_or_else(|| report!("missing hx status line"))
49            .attach_with(|| format!("pane_id={} text={wezterm_pane_text:#?}", hx_pane.pane_id))?,
50    )?;
51
52    let git_repo_root_path = Arc::new(ytil_git::repo::get_root(&ytil_git::repo::discover(
53        &hx_status_line.file_path,
54    )?));
55
56    let get_git_current_branch =
57        std::thread::spawn(move || -> rootcause::Result<String> { ytil_git::branch::get_current() });
58
59    let git_repo_root_path_clone = git_repo_root_path.clone();
60    let get_github_repo_url = std::thread::spawn(move || -> rootcause::Result<Url> {
61        match &ytil_gh::get_repo_urls(&git_repo_root_path_clone)?.as_slice() {
62            &[] => Err(report!("missing GitHub repo URL").attach(format!("repo_path={git_repo_root_path_clone:#?}")))?,
63            &[one] => Ok(one.clone()),
64            multi => Err(report!("multiple GitHub repo URLs")
65                .attach(format!("URLs={multi:#?} repo_path={git_repo_root_path_clone:#?}")))?,
66        }
67    });
68
69    // `build_file_path_relative_to_git_repo_root` are called before the threads `join` to let them work in the
70    // background as much as possible
71    let hx_cursor_absolute_file_path = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane)?;
72
73    let github_link = build_github_link(
74        &ytil_sys::join(get_github_repo_url)?,
75        &ytil_sys::join(get_git_current_branch)?,
76        hx_cursor_absolute_file_path.strip_prefix(git_repo_root_path.as_ref())?,
77        &hx_status_line.position,
78    )?;
79
80    ytil_sys::file::cp_to_system_clipboard(&mut github_link.as_str().as_bytes())?;
81
82    Ok(())
83}
84
85/// Builds absolute file path for Helix cursor position.
86///
87/// # Errors
88/// - Expanding a home-relative path (starting with `~`) fails because the home directory cannot be determined.
89fn build_hx_cursor_absolute_file_path(hx_cursor_file_path: &Path, hx_pane: &WeztermPane) -> rootcause::Result<PathBuf> {
90    if let Ok(hx_cursor_file_path) = hx_cursor_file_path.strip_prefix("~") {
91        return ytil_sys::dir::build_home_path(&[hx_cursor_file_path]);
92    }
93
94    let mut components = hx_pane.cwd.components();
95    components.next();
96    components.next();
97
98    Ok(std::iter::once(Component::RootDir)
99        .chain(components)
100        .chain(hx_cursor_file_path.components())
101        .collect())
102}
103
104/// Builds GitHub link pointing to specific file and line.
105///
106/// # Errors
107/// - UTF-8 conversion fails.
108fn build_github_link<'a>(
109    github_repo_url: &'a Url,
110    git_current_branch: &'a str,
111    file_path: &'a Path,
112    hx_cursor_position: &'a HxCursorPosition,
113) -> rootcause::Result<Url> {
114    let mut file_path_parts = vec![];
115    for component in file_path.components() {
116        file_path_parts.push(
117            component
118                .as_os_str()
119                .to_str()
120                .ok_or_else(|| report!("path component invalid utf-8"))
121                .attach_with(|| format!("component={component:#?}"))?,
122        );
123    }
124
125    let segments = [&["tree", git_current_branch], file_path_parts.as_slice()].concat();
126    let mut github_link = github_repo_url.clone();
127    github_link
128        .path_segments_mut()
129        .map_err(|()| {
130            report!("cannot extend url with segments").attach(format!("url={github_repo_url} segments={segments:#?}"))
131        })?
132        .extend(&segments);
133    github_link.set_fragment(Some(&format!(
134        "L{}C{}",
135        hx_cursor_position.line, hx_cursor_position.column
136    )));
137
138    Ok(github_link)
139}
140
141#[cfg(test)]
142mod tests {
143    use fake::Fake;
144    use fake::Faker;
145
146    use super::*;
147
148    #[test]
149    fn test_build_hx_cursor_absolute_file_path_when_path_is_home_relative_returns_home_path() {
150        // Arrange
151        temp_env::with_vars([("HOME", Some("/Users/Foo"))], || {
152            let hx_status_line = HxStatusLine {
153                file_path: Path::new("~/src/bar/baz.rs").into(),
154                ..Faker.fake()
155            };
156            let hx_pane = WeztermPane {
157                cwd: Path::new("file://hostname/Users/Foo/dev").into(),
158                ..Faker.fake()
159            };
160
161            // Act
162            let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane);
163
164            // Assert
165            let expected = Path::new("/Users/Foo/src/bar/baz.rs").to_path_buf();
166            assert_eq!(result.unwrap(), expected);
167        });
168    }
169
170    #[test]
171    fn test_build_hx_cursor_absolute_file_path_when_path_is_relative_returns_pane_cwd_path() {
172        // Arrange
173        let hx_status_line = HxStatusLine {
174            file_path: Path::new("src/bar/baz.rs").into(),
175            ..Faker.fake()
176        };
177        let hx_pane = WeztermPane {
178            cwd: Path::new("file://hostname/Users/Foo/dev").into(),
179            ..Faker.fake()
180        };
181
182        // Act
183        let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane).unwrap();
184
185        // Assert
186        let expected = Path::new("/Users/Foo/dev/src/bar/baz.rs").to_path_buf();
187        assert_eq!(expected, result);
188    }
189
190    #[test]
191    fn test_build_hx_cursor_absolute_file_path_when_path_is_absolute_returns_same_path() {
192        // Arrange
193        let hx_status_line = HxStatusLine {
194            file_path: Path::new("/Users/Foo/dev/src/bar/baz.rs").into(),
195            ..Faker.fake()
196        };
197        let hx_pane = WeztermPane {
198            cwd: Path::new("file://hostname/Users/Foo/dev").into(),
199            ..Faker.fake()
200        };
201
202        // Act
203        let result = build_hx_cursor_absolute_file_path(&hx_status_line.file_path, &hx_pane).unwrap();
204
205        // Assert
206        let expected = Path::new("/Users/Foo/dev/src/bar/baz.rs").to_path_buf();
207        assert_eq!(expected, result);
208    }
209}