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