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