Skip to main content

oseda_cli/cmd/
deploy.rs

1use std::{env, error::Error, fs, path::Path};
2
3use clap::Args;
4
5use crate::{
6    cmd::is_cwd_oseda_project,
7    config,
8    github::{self, git},
9};
10
11/// Options for the `oseda deploy` command
12#[derive(Args, Debug)]
13pub struct DeployOptions {
14    /// URL to fork of oseda-lib GitHub repository
15    #[arg(value_name = "FORK_URL")]
16    fork_url: String,
17    /// Run in quiet mode (e.g. do not open PR in browser)
18    #[arg(long, value_name = "QUIET")]
19    quiet: bool,
20}
21
22struct SshUrl(String);
23
24/// string deref
25impl std::ops::Deref for SshUrl {
26    type Target = String;
27
28    fn deref(&self) -> &Self::Target {
29        &self.0
30    }
31}
32
33/// Convert a standard HTTPS GitHub URL to SSH format
34///
35/// # Arguments
36/// * `value` - a String starting with `https://github.com/...`
37///
38/// # Returns
39/// * `Ok(SshUrl)` if parsing succeeds
40/// * `Err` if the format is not recognized
41impl TryFrom<String> for SshUrl {
42    type Error = Box<dyn Error>;
43
44    fn try_from(value: String) -> Result<Self, Self::Error> {
45        // https://github.com/ReeseHatfield/oseda-lib-testing/
46        // into
47        // git@github.com:ReeseHatfield/oseda-lib-testing.git
48        let suffix = value
49            .strip_prefix("https://github.com/")
50            .ok_or("Could not get SSH URL")?;
51
52        Ok(SshUrl(format!(
53            "git@github.com:{}.git",
54            suffix.trim_end_matches('/')
55        )))
56    }
57}
58
59/// Deploys an Oseda project to the provided fork URL
60///
61/// # Arguments
62/// * `opts` - options with the `fork_url` for the deployment target
63///
64/// # Returns
65/// * `Ok(())` on success
66/// * `Err` if any git, file, or config step fails, including a check failure
67pub fn deploy(opts: DeployOptions) -> Result<(), Box<dyn Error>> {
68    if !is_cwd_oseda_project() {
69        return Err("Current working directory is not an Oseda project".into());
70    }
71
72    let tmp_dir = tempfile::tempdir()?;
73    let repo_path = tmp_dir.path();
74
75    let ssh_url: SshUrl = opts.fork_url.try_into()?;
76
77    git(
78        repo_path,
79        &["clone", "--no-checkout", ssh_url.0.as_str(), "."],
80    )?;
81
82    println!("Running git with sparse checkout");
83    git(repo_path, &["sparse-checkout", "init", "--cone"])?;
84    git(repo_path, &["sparse-checkout", "set", "courses"])?;
85    git(repo_path, &["checkout"])?;
86
87    let course_name = get_current_dir_name()?;
88    let new_course_dir = repo_path.join("courses").join(&course_name);
89
90    copy_dir_all(env::current_dir()?, &new_course_dir)?;
91
92    // bails if config is bad
93    //
94    // force a no-skip-git
95    let conf = config::read_and_validate_config()?;
96
97    println!("Committing files to remote...");
98    git(repo_path, &["add", "."])?;
99    git(
100        repo_path,
101        &["commit", "-m", &format!("Add course: {}", conf.title)],
102    )?;
103    git(repo_path, &["push"])?;
104
105    config::update_time(conf)?;
106
107    println!("Project successfully pushed to remote.");
108
109    // https://github.com/oseda-dev/oseda-lib/compare/main...ReeseHatfield:oseda-lib:main?expand=1
110
111    match github::get_config_from_user_git("user.name") {
112        Some(github_username) => {
113            let pull_request_url = format!(
114                "https://github.com/oseda-dev/oseda-lib/compare/main...{}:oseda-lib:main?expand=1",
115                github_username
116            );
117
118            println!("Add your presentation to oseda.net by making a Pull Request at:");
119            println!();
120            println!("{}", pull_request_url);
121
122            if !opts.quiet {
123                open::that(pull_request_url.clone()).map_err(|_| {
124                    format!("Please visit {pull_request_url} in a browser and submit a pull-request by hand")
125                })?;
126            }
127        }
128        None => {
129            println!("Error: could not get github username");
130            return Err("Deployment failed due to missing github credential. Pleas ensure user.name matches your github username".into());
131        }
132    }
133
134    Ok(())
135}
136
137/// Util fn to get the current working directory name
138///
139/// # Returns
140/// * `Ok(String)` with the directory name
141/// * `Err` if the name failed to be extracted
142fn get_current_dir_name() -> Result<String, Box<dyn Error>> {
143    // this is like really stupid to have this, since
144    // this logic is basically already used in `check`
145    // but really most of that logic should be moved to a config.rs file
146    // but until then, I am just reading the cwd with this
147    let cwd = env::current_dir()?;
148    let name = cwd
149        .file_name()
150        .ok_or("couldn't get directory name")?
151        .to_string_lossy()
152        .to_string();
153    Ok(name)
154}
155
156/// Recursively copy a directory
157/// https://stackoverflow.com/questions/26958489/how-to-copy-a-folder-recursively-in-rust
158fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<(), Box<dyn Error>> {
159    let src = src.as_ref();
160    let dst = dst.as_ref();
161
162    fs::create_dir_all(dst)?;
163
164    for entry in fs::read_dir(src)? {
165        let entry = entry?;
166        let entry_path = entry.path();
167
168        // skip `.git` directory
169        if entry_path.ends_with(".git") {
170            continue;
171        }
172
173        let ty = entry.file_type()?;
174
175        if ty.is_dir() {
176            copy_dir_all(&entry_path, dst.join(entry.file_name()))?;
177        } else {
178            fs::copy(&entry_path, dst.join(entry.file_name()))?;
179        }
180    }
181
182    Ok(())
183}