コンテンツにスキップ

Rustで作るToDoリストCLI

このプロジェクトでは、ターミナルで動く小さなToDoリストを作ります。todo add 牛乳を買う と打てば、ちゃんと覚えておいてくれます。ターミナルを閉じて、次の日に todo list と打っても、タスクはそのまま残っています。

本物のツールらしく見えるので、2つ目のプロジェクトにぴったりです。構造体、列挙型、ベクタ、コマンドライン引数、ファイル入出力、そして ? と組み合わせた Result を使います。部品を1つずつ作ってPlaygroundで試し、最後に全部を組み合わせましょう。

できあがったツールは、こんなふうに使います。

Terminal window
$ cargo run -- add 牛乳を買う
タスク1を追加しました。
$ cargo run -- add おばあちゃんに電話する
タスク2を追加しました。
$ cargo run -- done 1
タスク1を完了にしました。
$ cargo run -- list
[x] 1: 牛乳を買う
[ ] 2: おばあちゃんに電話する
$ cargo run -- remove 1
タスク1を削除しました: 牛乳を買う

-- より後ろの部分は、Cargoではなくあなたのプログラムに渡されます。タスクは tasks.txt というファイルに、1行に1つずつ保存されます。

2|0|おばあちゃんに電話する

各行は id|done|text という形です。done は、完了なら 1、未完了なら 0 です。

まずは新しいプロジェクトを作りましょう。

Terminal window
cargo new todo
cd todo

どのタスクにも、番号(あとで指定するため)、完了フラグ、内容のテキストがあります。これを表すには構造体がぴったりです。さらにメソッドを2つ用意します。タスクをファイル用の1行のテキストに変えるメソッドと、1行のテキストからタスクを読み戻すメソッドです。

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// タスクを "1|0|牛乳を買う" のような1行に変換する。
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// 1行からタスクを読み戻す。行が壊れていたら None を返す。
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
fn main() {
let task = Task { id: 1, done: false, text: String::from("牛乳を買う") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|おばあちゃんに電話する"));
println!("{:?}", Task::from_line("3|0|A|Bテストについて読む"));
println!("{:?}", Task::from_line("この行は壊れています"));
}

実行結果:

1|0|牛乳を買う
Some(Task { id: 2, done: true, text: "おばあちゃんに電話する" })
Some(Task { id: 3, done: false, text: "A|Bテストについて読む" })
None
▶ 実際に試してみよう

ここで、2つのポイントをじっくり見ておきましょう。

  • splitn(3, '|') は、最大で3つに分割します。最初の2つの | がフィールドを区切り、それより後ろは、たとえ | を含んでいても全部がテキストになります。ただの split('|') を使うと、「A|Bテストについて読む」というタスクはテキストの半分が失われてしまいます。
  • from_line は Option を返し、各ステップで ? を使っています。どこかの部品が欠けていたり、idが数値でなかったりすると、関数全体が None を返します。.ok() は parse() が返す Result を Option に変換して、? を使えるようにしています。

このツールが理解するコマンドは4つです。それぞれを列挙型のバリアントにします。データを持つバリアントもあります。Add にはテキストが、Done と Remove にはタスク番号が必要です。

#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("タスク番号を指定してください"))?;
arg.parse().map_err(|_| format!("「{arg}」はタスク番号ではありません"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("使い方: todo add <タスクの内容>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("「{other}」というコマンドはありません")),
}
}
fn main() {
let examples = [
vec!["add", "スーパーで", "牛乳を買う"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "two"],
vec!["remove"],
vec!["dance"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

実行結果:

["add", "スーパーで", "牛乳を買う"] -> Ok(Add("スーパーで 牛乳を買う"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "two"] -> Err("「two」はタスク番号ではありません")
["remove"] -> Err("タスク番号を指定してください")
["dance"] -> Err("「dance」というコマンドはありません")
▶ 実際に試してみよう

実際のプログラムでは、args はコマンドラインから受け取ります。コマンドライン引数のページで見たとおり、std::env::args() の最初の要素はプログラム自身の名前なので、それは飛ばします。

let args: Vec<String> = std::env::args().skip(1).collect();

なぜ add では args[1..].join(" ") を使うのでしょうか。シェルは todo add スーパーで 牛乳を買う を、add、スーパーで、牛乳を買う という別々の単語に分けてしまいます。add より後ろを全部つなぎ直せば、タスクのテキストが元どおりになるので、引用符で囲む必要がありません。

parse_command は決してクラッシュしない点にも注目してください。おかしな入力は、わかりやすいメッセージ付きの Err になります。それをどう扱うかは、呼び出す側が決めます。

ステップ3: タスクの一覧を操作する

Section titled “ステップ3: タスクの一覧を操作する”

タスクはすべて Vec<Task> に入れます。コマンドごとに、ベクタを変更する小さな関数を用意します。

  • Add:今までで一番大きいidを探して1を足し、新しいタスクを追加します。
  • Done:そのidのタスクを探して、done = true にします。
  • Remove:タスクの位置を探して、取り除きます。
  • List:すべてのタスクを表示します。
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("タスク{id}はありません")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("タスク{id}はありません")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("やることはありません。よい一日を!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn main() {
let mut tasks = Vec::new();
add_task(&mut tasks, String::from("牛乳を買う"));
add_task(&mut tasks, String::from("おばあちゃんに電話する"));
add_task(&mut tasks, String::from("Rustを学ぶ"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("削除しました: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("エラー: {message}");
}
print_tasks(&tasks);
}

実行結果:

削除しました: 牛乳を買う
エラー: タスク99はありません
[ ] 2: おばあちゃんに電話する
[x] 3: Rustを学ぶ
▶ 実際に試してみよう

いくつかの書き方は、わざとそうしています。

  • idは、今ある一番大きいidから決めます。tasks.len() + 1 ではありません。タスク1を削除してから新しいタスクを追加すると、長さを使った場合はidが重複してしまいます。
  • mark_done は &mut [Task](スライス)を受け取ります。要素を変更するだけだからです。一方、add_task と remove_task は長さを変えるので &mut Vec<Task> を受け取ります。
  • エラーは Result<_, String> にしています。String のエラーは、動くものとしては一番シンプルです。もっと大きなプログラムでは、独自のエラー型を見てください。

iter_mut().find(...) は、見つかったタスクへの可変参照を返します。だから task.done = true で、ベクタの中のタスクそのものが書き換わるのです。find と position について詳しくはイテレータのページを見てください。

ステップ4: ファイルへの保存と読み込み

Section titled “ステップ4: ファイルへの保存と読み込み”

次は、プログラムが終わってもタスクが消えないようにします。保存には std::fs::write、読み込みには std::fs::read_to_string を使います。どちらも失敗する可能性があるので Result を返し、? 演算子がエラーを呼び出し元へ渡します。

use std::fs;
use std::io;
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
// 初めてプログラムを実行したときは、まだファイルがない。
// これはエラーではなく、単に「タスクがない」という意味。
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}

NotFound のケースは、見た目以上に大切です。これがないと、一番最初の todo add が「No such file or directory」(ファイルやディレクトリがありません)で失敗してしまいます。まだ何も保存していないからです。それ以外のエラー(「permission denied」=権限がない、など)は、ちゃんと呼び出し元へ渡されます。

filter_map(Task::from_line) は各行を読み、正しく解析できた行だけを残します。つまり、壊れた行は何も言わずに読み飛ばされることに注意してください。誰かが手でファイルを編集して打ち間違えたときにはやさしい動きですが、ミスに気づかないままになるおそれもあります。下の「拡張のアイデア」では、代わりに警告を出す案を紹介しています。

ファイルの読み書きについて詳しくはファイル入出力のページを見てください。

ステップ5: プログラムの完成形

Section titled “ステップ5: プログラムの完成形”

全部をまとめるとこうなります。src/main.rs に書いて、cargo run -- add 牛乳を買う や cargo run -- list などで実行してみましょう。

中心となる処理は run_command にあります。これは Vec<Task> を操作するだけで、ファイルのことは何も知りません。main はファイルを読み込み、run_command を呼び、保存するだけです。この2つを分けておくと処理をテストしやすくなりますし、次のステップでPlayground版を作れるのもこのおかげです。

use std::env;
use std::error::Error;
use std::fs;
use std::io;
use std::process;
const FILE: &str = "tasks.txt";
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("タスク番号を指定してください"))?;
arg.parse().map_err(|_| format!("「{arg}」はタスク番号ではありません"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("使い方: todo add <タスクの内容>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("「{other}」というコマンドはありません")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("タスク{id}はありません")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("タスク{id}はありません")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("やることはありません。よい一日を!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("タスク{id}を追加しました。");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("タスク{id}を完了にしました。");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("タスク{id}を削除しました: {}", task.text);
}
}
Ok(())
}
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}
fn run() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().skip(1).collect();
let mut tasks = load_tasks(FILE)?;
run_command(&args, &mut tasks)?;
save_tasks(FILE, &tasks)?;
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("エラー: {e}");
process::exit(1);
}
}

run() -> Result<(), Box<dyn Error>> について補足します。このプログラムの関数は、2種類のエラー型を返します。ファイル関係の関数は io::Error、コマンド関係の関数は String です。Box<dyn Error> はどんなエラー型でも入れられる箱なので、どちらにも ? が使えます。main は eprintln!(エラー出力に書き出すマクロ)でエラーを表示し、終了コード1で終了します。これは「何かがうまくいかなかった」ことを伝える一般的な合図です。

ステップ6: Playgroundで動かせるバージョン

Section titled “ステップ6: Playgroundで動かせるバージョン”

Playgroundではコマンドライン引数を渡せませんし、ファイルに頼るのも避けたいところです。そこでこのバージョンでは、同じ run_command をそのまま使い、あらかじめ用意したコマンドの一覧を順に渡します。最後に、tasks.txt に保存される内容を表示します。

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("タスク番号を指定してください"))?;
arg.parse().map_err(|_| format!("「{arg}」はタスク番号ではありません"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("使い方: todo add <タスクの内容>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("「{other}」というコマンドはありません")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("タスク{id}はありません")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("タスク{id}はありません")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("やることはありません。よい一日を!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("タスク{id}を追加しました。");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("タスク{id}を完了にしました。");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("タスク{id}を削除しました: {}", task.text);
}
}
Ok(())
}
fn main() {
// 各行は `todo` の後ろに打つ内容。
let script = [
"list",
"add 牛乳を買う",
"add おばあちゃんに電話する",
"add Rustを学ぶ",
"done 3",
"remove 1",
"done 9",
"jump",
"list",
];
let mut tasks: Vec<Task> = Vec::new();
for line in script {
println!("$ todo {line}");
let args: Vec<String> = line.split_whitespace().map(String::from).collect();
if let Err(message) = run_command(&args, &mut tasks) {
println!("エラー: {message}");
}
}
println!();
println!("tasks.txt に保存される内容:");
for task in &tasks {
println!("{}", task.to_line());
}
}

実行結果:

$ todo list
やることはありません。よい一日を!
$ todo add 牛乳を買う
タスク1を追加しました。
$ todo add おばあちゃんに電話する
タスク2を追加しました。
$ todo add Rustを学ぶ
タスク3を追加しました。
$ todo done 3
タスク3を完了にしました。
$ todo remove 1
タスク1を削除しました: 牛乳を買う
$ todo done 9
エラー: タスク9はありません
$ todo jump
エラー: 「jump」というコマンドはありません
$ todo list
[ ] 2: おばあちゃんに電話する
[x] 3: Rustを学ぶ
tasks.txt に保存される内容:
2|0|おばあちゃんに電話する
3|1|Rustを学ぶ
▶ 実際に試してみよう

スクリプトを書き換えて、もう一度実行してみましょう。タスクを削除したあとに新しいタスクを追加して、idが重複しないことを確かめてみてください。

ステップアップ: serdeでJSONとして保存する

Section titled “ステップアップ: serdeでJSONとして保存する”

id|done|text という形式は読みやすいのですが、to_line と from_line を手で書く必要がありました。タスクの項目が増えていくと(期限、優先度、タグなど)、手書きのコードもどんどん増えていきます。

実際のプロジェクトでは、この作業は serde(英語)のクレートに任せるのが一般的です。次のコマンドで追加します。

Terminal window
cargo add serde --features derive
cargo add serde_json

あとは derive の1行を書くだけで、保存と読み込みの処理が自分で書かなくても手に入ります。

use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn main() {
let tasks = vec![
Task { id: 1, done: false, text: String::from("牛乳を買う") },
Task { id: 2, done: true, text: String::from("おばあちゃんに電話する") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("{}件のタスクを読み込み直しました", loaded.len());
}

実行結果:

[{"id":1,"done":false,"text":"牛乳を買う"},{"id":2,"done":true,"text":"おばあちゃんに電話する"}]
2件のタスクを読み込み直しました
▶ 実際に試してみよう

このJSON文字列を、前と同じように fs::write で tasks.json に書き込めばOKです。詳しくはSerdeでJSONを扱うのページで解説しています。

何も返さない main の中で ? を使う。 ついこう書きたくなります。

use std::fs;
fn main() {
let text = fs::read_to_string("tasks.txt")?;
println!("{text}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:47
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let text = fs::read_to_string("tasks.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`

コンパイラは「? 演算子は Result か Option を返す関数の中でしか使えません」と言っています。? は「エラーだったら、それを今の関数から返す」という意味です。ふつうの main は () を返すので、エラーの送り先がありません。main に Result<(), Box<dyn Error>> のような戻り値の型をつけるか、このプログラムのように、処理を Result を返す run() 関数にまとめて、main でエラーを1回だけ処理しましょう。

ファイルがないことを失敗として扱う。 load_tasks がただ fs::read_to_string(path)? としていたら、まだファイルがない最初の実行で失敗してしまいます。io::ErrorKind::NotFound をチェックして、代わりに空の一覧から始めましょう。

一覧の長さを次のidにする。 タスク1、2、3があるときにタスク1を削除すると、タスクは2つ残ります。len() + 1 だとまた 3 になり、2つのタスクが同じidを持ってしまいます。一番大きいidに1を足しましょう。

  • 「完了」を取り消す。 done を false に戻す undone <id> コマンドを追加しましょう。
  • タスクを編集する。 edit 2 今夜おばあちゃんに電話する で、タスク2のテキストを置き換えます。
  • 完了したタスクを片付ける。 clear で、完了済みのタスクをすべて削除します。Vec::retain(英語)を調べてみてください。
  • 壊れた行を警告する。 壊れた行を黙って読み飛ばす代わりに、行番号付きで警告を表示します。
  • help コマンドを作って、すべてのコマンドとその使い方を表示します。
  • テストを書く。 parse_command と run_command はファイルに触れないので、簡単にテストできます。
  • 本格的な引数パーサーを使う。 コマンドライン引数のページで紹介した clap(英語)クレートを使えば、--help やサブコマンドを自分で書かずに使えます。
  • Task 構造体がデータを持ちます。to_line と from_line で、テキストとの間を行き来します。
  • Command 列挙型は、ユーザーが何を頼んだかを表します。parse_command は引数を Command に変換するか、わかりやすいメッセージ付きの Err を返します。
  • Vec<Task> が一覧を持ちます。小さな関数で、タスクの追加・検索・変更・削除を行います。
  • fs::read_to_string と fs::write は Result を返し、? がエラーを呼び出し元へ渡します。最初の実行でファイルがないときは「タスクなし」として扱います。
  • 処理(run_command)をファイルや引数から切り離しておくと、テストしやすく、Playgroundでも動かせます。

理解度チェック

1. from_line が split('|') ではなく splitn(3, '|') を使うのはなぜですか?

2. tasks.txt がまだ存在しないとき、load_tasks はどうしますか?

3. 戻り値の型がない fn main() の中で、? を直接使えないのはなぜですか?

4. 構造体をJSONとして保存したいとき、定番のステップアップ先はどのクレートですか?

スコア: 0 / 4