こんにちは、ナナオです。

私のブログはhugoというツールで投稿しているのですが、静的ビルドを行ってデプロイするため、予約投稿ができません。

よくある方法として、Github Actionsを使った方法がありますが、今回はRustを使った超ミニマムなCMSを作って予約投稿をしていきたいと思います。

仕様

CMSとは言っているものの、ミニマム実装の段階ではHugoのディレクトリ監視ツールのようなものです。

以下のような設定のconfig.yamlファイルを読み込み、dateの時間になったらhugoのビルドとデプロイを行います。

# 監視対象のhugoプロジェクトディレクトリパスを指定
workdir: path/to/hugo_project
# スケジュールされた記事の情報
scheduled:
- date: 2026/1/20 00:00:00 # 予約投稿する日時
  file: posts/2026/01/test.md # 予約投稿する記事のディレクトリ(workdirからの相対パス)

実装

早速実装していきます。

実装は特にこだわりはないですが、やはり最高のプログラミング言語Rustで行います。テンション上がるしね。

use chrono::{Local, NaiveDateTime};
use regex::Regex;
use serde::Deserialize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::Duration;

// 設定ファイルの構造体定義
#[derive(Debug, Deserialize)]
struct Config {
    workdir: String,
    scheduled: Vec<ScheduledPost>,
}

#[derive(Debug, Deserialize)]
struct ScheduledPost {
    date: String,
    file: String,
}

fn main() {
    println!("Hugo Local CMS: 予約投稿監視を開始しました...");

    // 処理済みファイルを記録するSet (日付+ファイル名 をキーにする)
    let mut processed: HashSet<String> = HashSet::new();

    loop {
        println!("{} ファイルチェック開始", Local::now().to_rfc3339());
        // 設定ファイルの読み込み
        let config = match load_config("config.yaml") {
            Ok(cfg) => cfg,
            Err(e) => {
                eprintln!("設定読み込みエラー: {}", e);
                thread::sleep(Duration::from_secs(10));
                continue;
            }
        };

        let now = chrono::Local::now().naive_local();

        for post in config.scheduled {
            let key = format!("{}{}", post.date, post.file);

            // 既に処理済みならスキップ
            if processed.contains(&key) {
                continue;
            }

            // 日付パース (フォーマット: 2026/01/20 18:45:00)
            let pub_date = match NaiveDateTime::parse_from_str(&post.date, "%Y/%m/%d %H:%M:%S") {
                Ok(dt) => dt,
                Err(e) => {
                    eprintln!("日付パースエラー ({}): {}", post.file, e);
                    continue;
                }
            };

            // 時間チェック
            if now > pub_date {
                println!("⏰ 予約時刻になりました: {}", post.file);

                // publish処理を実行
                publish(&config.workdir, &post.file);

                // 処理済みリストに追加
                processed.insert(key);
            }
        }

        println!("{} ファイルチェック終了", Local::now().to_rfc3339());
        // 30秒待機
        thread::sleep(Duration::from_secs(30));
    }
}

fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?;
    let config: Config = serde_yaml::from_str(&content)?;
    Ok(config)
}

fn publish(work_dir: &str, relative_path: &str) {
    let full_path = Path::new(work_dir).join(relative_path);

    // 1. ファイルのドラフト解除
    if let Err(e) = modify_draft_status(&full_path) {
        eprintln!("❌ ファイル修正失敗 [{:?}]: {}", full_path, e);
        return;
    }

    // 2. Hugoコマンド実行
    execute_hugo(work_dir);
}

fn modify_draft_status(path: &PathBuf) -> std::io::Result<()> {
    // ファイル読み込み
    let content = fs::read_to_string(path)?;

    // 正規表現: 行頭の "draft = true" (スペース許容) を検索
    // (?m) はマルチラインモード有効化
    let re = Regex::new(r"(?m)^(draft\s*=\s*)true").unwrap();

    if re.is_match(&content) {
        // 置換実行 ( ${1} は "draft = " の部分 )
        let new_content = re.replace(&content, "${1}false");

        // 書き込み
        fs::write(path, new_content.as_bytes())?;
        println!(
            "📝 Draftフラグを解除しました: {:?}",
            path.file_name().unwrap()
        );
    } else {
        println!("ℹ️ Draftフラグの変更不要: {:?}", path.file_name().unwrap());
    }

    Ok(())
}

fn execute_hugo(work_dir: &str) {
    println!("🏗️ Hugoビルドを開始します...");

    // 1. hugo
    if run_command(work_dir, "hugo", &[]).is_err() {
        return;
    }

    // 2. hugo deploy
    if run_command(work_dir, "hugo", &["deploy"]).is_err() {
        return;
    }

    println!("✅ Hugoのビルドとデプロイが完了しました。");
}

fn run_command(dir: &str, command: &str, args: &[&str]) -> Result<(), ()> {
    println!("🚀 実行中: {} {:?} (in {})", command, args, dir);

    let status = Command::new(command)
        .args(args)
        .current_dir(dir) // 作業ディレクトリ指定
        .status(); // 実行して終了ステータスを待つ

    match status {
        Ok(s) if s.success() => Ok(()),
        Ok(s) => {
            eprintln!("エラー: コマンドが失敗しました (Exit Code: {:?})", s.code());
            Err(())
        }
        Err(e) => {
            eprintln!("エラー: コマンドを実行できませんでした: {}", e);
            Err(())
        }
    }
}

実装内では30秒ごとにconfig.yamlを走査します。

中に定義されている予約投稿の日時になったら予約投稿を行います。

あとはcargo runで監視を開始します。

感想

簡単な実装ですが、予約投稿機能を実現できました。

UIといったリッチな機能は後回しにして、本当に欲しい機能にのみ焦点を当てて作りました。

まだ投稿されていない投稿から予約投稿する記事を選択するUIくらいは用意されていてもいいな、と思いました。

ただそのためにactixとかrocket使うのはなんだか面倒、ということでどうしようかちょっと悩んでいます。

続くかも?